From 4de5ad9085f696558f7e45152a0e72d562e7d370 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 00:40:50 +1030 Subject: [PATCH 001/107] Add a package for security regression tests --- tests/security/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/security/__init__.py diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..1fe6dea --- /dev/null +++ b/tests/security/__init__.py @@ -0,0 +1 @@ +"""Security regression tests: each module demonstrates one audit finding and checks its fix.""" From 203402a8d2fe11479fc4115e7c319033f5989977 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 00:51:58 +1030 Subject: [PATCH 002/107] Add security tests for zero-size file children, user pins, folder swaps, 507 messages and get's free space --- tests/security/_content_helpers.py | 117 ++++++++++++++ tests/security/test_i1_507_message.py | 90 +++++++++++ tests/security/test_l3_zero_size_children.py | 75 +++++++++ tests/security/test_l8_folder_swap.py | 152 +++++++++++++++++++ tests/security/test_l9_get_free_space.py | 64 ++++++++ tests/security/test_n1_user_pin_unbounded.py | 48 ++++++ 6 files changed, 546 insertions(+) create mode 100644 tests/security/_content_helpers.py create mode 100644 tests/security/test_i1_507_message.py create mode 100644 tests/security/test_l3_zero_size_children.py create mode 100644 tests/security/test_l8_folder_swap.py create mode 100644 tests/security/test_l9_get_free_space.py create mode 100644 tests/security/test_n1_user_pin_unbounded.py diff --git a/tests/security/_content_helpers.py b/tests/security/_content_helpers.py new file mode 100644 index 0000000..2e1b2dd --- /dev/null +++ b/tests/security/_content_helpers.py @@ -0,0 +1,117 @@ +"""Shared helpers for the content-group security tests (not a test module): a DAG that declares 1 byte of content +but holds megabytes of file nodes, and two offline nodes where B fetches from A over a NetClient that counts what +B downloads.""" +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.constants import MAX_BLOCK_SIZE +from rnfs.content import dag +from rnfs.content.cid import DAG, EMPTY_FILE, RAW, Cid +from rnfs.content.storage import USER +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, B_ID, NetClient + +# Distinct file nodes made only of empty chunks below the bomb's root, and the chunks in the first of them +BOMB_NODES = 100 +BOMB_CHILDREN = 500 # about 20 KB of node per file node: 2 MB of them in all +# What fetching a root that declares N content bytes may download beyond N: two full DAG nodes. An honest +# 1-byte file is a single 1-byte block, so this leaves room for any per-node overhead a bound could allow +SLACK = 2 * MAX_BLOCK_SIZE + + +class Bomb: + """A file root whose content is b"x" (1 byte) but whose DAG holds BOMB_NODES distinct file nodes of empty + chunks: zero-size children, which nothing on the read side rejects (audit L3).""" + + def __init__(self, nodes: int = BOMB_NODES, children: int = BOMB_CHILDREN): + """Build the blocks into self.blocks; self.root is the root's CID.""" + self.blocks: dict[Cid, bytes] = {EMPTY_FILE: b""} + # Each node has a different number of empty chunks, so each is a distinct block that must be fetched + empties = [self.node(dag.FileNode([dag.Child(EMPTY_FILE, 0)] * (children + i))) for i in range(nodes)] + self.content = b"x" + x = Cid.for_block(RAW, self.content) + self.blocks[x] = self.content + self.root = self.node(dag.FileNode([dag.Child(cid, 0) for cid in empties] + [dag.Child(x, 1)])) + self.size = len(self.content) # what the root declares: the sum of its children's sizes + + def node(self, node) -> Cid: + """Encode node and keep its block.""" + block = dag.encode(node) + cid = Cid.for_block(DAG, block) + self.blocks[cid] = block + return cid + + def total_bytes(self) -> int: + """Bytes of every distinct block of the DAG.""" + return sum(len(block) for block in self.blocks.values()) + + +class CountingNetClient(NetClient): + """A NetClient that adds up the blocks every /blocks request returns: what the fetching node downloaded.""" + + def __init__(self): + """Nothing downloaded yet.""" + super().__init__() + self.downloaded_blocks = 0 + self.downloaded_bytes = 0 + + def blocks(self, node_id, cids, on_progress=None): + """NetClient.blocks, counted.""" + result = super().blocks(node_id, cids, on_progress) + self.downloaded_blocks += len(result.blocks) + self.downloaded_bytes += sum(len(block) for block in result.blocks.values()) + return result + + def reset(self): + """Start counting again.""" + self.downloaded_blocks = self.downloaded_bytes = 0 + + +class TwoNodeTest(unittest.TestCase): + """Two offline nodes, as tests/test_node.py's NodeTest: A serves what it provides and B fetches it. B uses the + default config limits (no max_download_size, max_output_entries 100,000) unless b_settings says otherwise.""" + + b_settings: dict = {} + + def setUp(self): + """Fresh nodes in a temporary directory, with RNS's log silenced.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.net = CountingNetClient() + self.net.sender = B_ID + self.a = self.make_node("a", A_ID) + self.b = self.make_node("b", B_ID, **self.b_settings) + self.b.routing.heard(A_ID) + + def make_node(self, name: str, node_id: bytes, **settings) -> RnfsNode: + """An offline node with its own directory (settings as NodeTest.make_node).""" + config = Config(rnfs_dir=os.path.join(self.tmp.name, name), slow="no", lookup_timeout=5, path_timeout=0.5, + **settings) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork()) + self.net.nodes[node_id] = node + self.addCleanup(node.stop) + return node + + def serve(self, blocks: dict[Cid, bytes], root: Cid): + """Store blocks on A, pin root there and advertise A as its provider, so B can fetch it.""" + with self.a.storage.protect() as session: + for cid, block in blocks.items(): + session.put(cid, block) + self.a.storage.pin(root, USER) + self.a.publisher.refresh() + self.a.records.store(root.key, A_ID) + self.assertIsNotNone(self.a.backend.served_block(root), "A should serve the root") + + def serve_bomb(self) -> Bomb: + """A Bomb that A serves.""" + bomb = Bomb() + self.serve(bomb.blocks, bomb.root) + return bomb diff --git a/tests/security/test_i1_507_message.py b/tests/security/test_i1_507_message.py new file mode 100644 index 0000000..af8d47a --- /dev/null +++ b/tests/security/test_i1_507_message.py @@ -0,0 +1,90 @@ +"""I1: a gateway 507 (storage full) carries the StorageFull message as is, which names the local blocks directory +and the disk's free space and reserve. + +A real offline node holds a file root whose chunks claim 2**60 bytes each; the gateway's GET of it fails the disk +check before anything is fetched, and the 507's body is read back. The disk usage is fixed by a mock so the sizes in +the message are known. The fixed behaviour sends a generic message for 507, as it already does for 500. +""" +import os +import shutil +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs.config import Config +from rnfs.content import dag +from rnfs.content import storage as storage_module +from rnfs.content.cid import DAG, RAW, Cid +from rnfs.content.storage import USER +from rnfs.gateway import GatewayServer +from rnfs.log import size +from rnfs.node import RnfsNode +from tests.test_api import raw_request +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, NetClient + +TOTAL = 1024 ** 4 # the mocked filesystem: 1 TiB, 123,456,789,012 bytes free +FREE = 123_456_789_012 + + +class GatewayStorageFullTest(unittest.TestCase): + """The gateway of an offline node, asked for a file far bigger than its disk.""" + + def setUp(self): + """A node holding a huge root's block, and its gateway on a free port.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.config = Config(rnfs_dir=os.path.join(tmp.name, "a"), slow="no", lookup_timeout=1, path_timeout=0.2) + self.node = RnfsNode.offline(self.config, A_ID, NetClient(), FakeNetwork()) + self.addCleanup(self.node.stop) + # Only the root block is needed to know its size: one real chunk, then chunks claiming 2**60 bytes each + first = b"

hi

" * 100 + first_cid = Cid.for_block(RAW, first) + block = dag.encode(dag.FileNode([dag.Child(first_cid, len(first))] + + [dag.Child(Cid.for_block(RAW, b"x"), 2 ** 60)] * 4)) + self.root = Cid.for_block(DAG, block) + with self.node.storage.protect() as session: + session.put(self.root, block) + session.put(first_cid, first) + self.node.storage.pin(self.root, USER) + self.server = GatewayServer(self.node, self.config, 0) + self.server.start() + self.addCleanup(self.server.stop) + usage = shutil.disk_usage(tmp.name)._replace(total=TOTAL, free=FREE) + disk = mock.patch("shutil.disk_usage", return_value=usage) + disk.start() + self.addCleanup(disk.stop) + self.reserve = max(storage_module.DISK_RESERVE_MIN, int(TOTAL * storage_module.DISK_RESERVE_FRACTION)) + + def get_root(self) -> tuple[int, str]: + """(status, body) of the gateway's answer to a GET of the huge root.""" + status, _, body = raw_request(self.server.port, "GET", f"/rnfs/{self.root}") + return status, body.decode("utf-8", "replace") + + def local_details(self, body: str) -> list[str]: + """What of the node's directories and disk figures appears in body.""" + details = [self.config.rnfs_dir, self.config.blocks_dir, size(FREE), size(self.reserve)] + return [detail for detail in details if detail in body] + + def test_507_names_the_blocks_directory_and_disk_space(self): + status, body = self.get_root() + self.assertEqual(status, 507, body) + self.assertIn(self.config.blocks_dir, body) + self.assertIn(f"{size(FREE)} free", body) + self.assertIn(size(self.reserve), body) + + # I1: fails until the gateway sends a generic message for 507, without the local path or disk figures + @unittest.expectedFailure + def test_507_message_is_generic(self): + status, body = self.get_root() + self.assertEqual(status, 507, body) + self.assertEqual(self.local_details(body), [], f"the 507 body reveals local details: {body!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l3_zero_size_children.py b/tests/security/test_l3_zero_size_children.py new file mode 100644 index 0000000..aae980a --- /dev/null +++ b/tests/security/test_l3_zero_size_children.py @@ -0,0 +1,75 @@ +"""L3: a file node may list children of size 0, so a file that declares 1 byte can make `get` download an unbounded +DAG of empty file nodes. + +B gets, through the real Fetcher and default config limits, a root served by A whose content is b"x" but whose DAG +holds about 2 MB of distinct file nodes made only of empty chunks. `get` downloads every one of them (and, with a +small max_storage, evicts an unrelated cached fetch to make room), while reading the same file the way `cat` does +fetches 2 blocks. The fixed behaviour bounds a `get` by the declared size plus per-node overhead. +""" +import io +import os +import unittest + +from rnfs.constants import CHUNK_SIZE +from rnfs.content.cid import Cid +from rnfs.errors import RnfsError +from tests.security._content_helpers import SLACK, TwoNodeTest +from tests.test_node import A_ID + + +class ZeroSizeChildrenTest(TwoNodeTest): + """A 1-byte file whose DAG is mostly empty file nodes, fetched by B from A.""" + + # Small enough that 2 MB of empty file nodes doesn't fit beside the cached file below + b_settings = {"max_storage": 1024 * 1024} + + def setUp(self): + """A serves the bomb, and an unrelated 192 KiB file B has already fetched (a cached fetch).""" + super().setUp() + self.bomb = self.serve_bomb() + self.honest = self.a.add_stream(io.BytesIO(os.urandom(3 * CHUNK_SIZE)))["cid"] + self.a.records.store(Cid.parse(self.honest).key, A_ID) + self.b.get(self.honest, os.path.join(self.tmp.name, "honest")) + self.assertTrue(self.honest_cached()) + self.net.reset() + + def honest_cached(self) -> bool: + """True while B still holds the unrelated file as a complete cached fetch.""" + cid = Cid.parse(self.honest) + return cid in self.b.storage.cached_targets() and self.b.storage.is_complete(cid) + + def test_get_downloads_every_empty_file_node(self): + self.assertEqual(self.b.info(str(self.bomb.root))["size"], 1) # what info and the gateway show + out = os.path.join(self.tmp.name, "out") + self.b.get(str(self.bomb.root), out) + with open(out, "rb") as f: + self.assertEqual(f.read(), b"x") + # Every distinct block of the DAG came over the network (the root for info, the rest for get) to write + # 1 byte: about 2 MB + self.assertEqual(self.net.downloaded_blocks, len(self.bomb.blocks)) + self.assertEqual(self.net.downloaded_bytes, self.bomb.total_bytes()) + self.assertGreater(self.net.downloaded_bytes, 2_000_000) # over 8 times what the fixed bound allows + self.assertGreater(self.net.downloaded_bytes, 8 * (self.bomb.size + SLACK)) + # Room for it was made by evicting the unrelated cached fetch + self.assertFalse(self.honest_cached()) + + def test_read_range_fetches_two_blocks(self): + self.assertEqual(b"".join(self.b.read(str(self.bomb.root))), b"x") + self.assertEqual(self.net.downloaded_blocks, 2) # the root and the 1-byte chunk: empty subtrees skipped + self.assertTrue(self.honest_cached()) + + # L3: fails until a get of a root declaring N bytes downloads at most about N bytes plus per-node overhead + @unittest.expectedFailure + def test_get_downloads_about_the_declared_size(self): + out = os.path.join(self.tmp.name, "out") + try: + self.b.get(str(self.bomb.root), out) + except RnfsError: + pass # refusing the root is as safe as writing it out lazily + self.assertLessEqual(self.net.downloaded_bytes, self.bomb.size + SLACK, + f"get of a root declaring {self.bomb.size} byte downloaded " + f"{self.net.downloaded_bytes} bytes in {self.net.downloaded_blocks} blocks") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l8_folder_swap.py b/tests/security/test_l8_folder_swap.py new file mode 100644 index 0000000..bb84a49 --- /dev/null +++ b/tests/security/test_l8_folder_swap.py @@ -0,0 +1,152 @@ +"""L8: where directories are walked by path (FD_WALK is False, as on Windows), swapping a checked directory in the +shared folder for a junction during a scan imports files from outside the folder. + +Simulated on any POSIX system: builder.FD_WALK is forced to False and a symlink stands in for the junction. The swap +happens at a fixed point of the scan (after the signature walk of a top-level entry, or right after a nested +directory passed its check), so there is no timing race. With the path walk the outside file is imported and pinned +"folder" (so the node provides it); with the descriptor walk (FD_WALK True, the real setting on Linux and macOS) +it never is. +""" +import os +import tempfile +import unittest +from contextlib import contextmanager +from unittest import mock + +from rnfs.content import builder, dag, shared_folder, walker +from rnfs.content.cid import RAW, Cid +from rnfs.content.shared_folder import SharedFolder +from rnfs.content.storage import FOLDER, Storage +from tests.support import CAN_SYMLINK + +SECRET = b"TOP SECRET: outside the shared folder" + + +@unittest.skipUnless(CAN_SYMLINK, "creating symlinks isn't allowed here") +class FolderSwapTest(unittest.TestCase): + """A shared folder holding drop/sub/harmless.txt, and a private directory outside it holding secret.txt.""" + + def make_folder(self): + """Storage, the shared folder and the private directory in a new temporary directory.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.private = os.path.join(self.tmp.name, "private") + os.mkdir(self.private) + with open(os.path.join(self.private, "secret.txt"), "wb") as f: + f.write(SECRET) + self.files = os.path.join(self.tmp.name, "files") + self.drop = os.path.join(self.files, "drop") + os.makedirs(os.path.join(self.drop, "sub")) + with open(os.path.join(self.drop, "sub", "harmless.txt"), "wb") as f: + f.write(b"hi") + self.storage = Storage(os.path.join(self.tmp.name, "blocks"), os.path.join(self.tmp.name, "state"), 10**9) + self.folder = SharedFolder(self.files, self.storage, os.path.join(self.tmp.name, "state")) + + def swap(self, path: str): + """What the attacker does: move the directory at path aside and put a link to the private one there.""" + os.rename(path, path + ".moved") + os.symlink(self.private, path) + + @contextmanager + def swap_after_signature(self): + """Swap drop once the scan has checked it and read its signature, before it is imported.""" + real = shared_folder.open_signature + + def signature_then_swap(handle, st, path): + """open_signature, then the swap.""" + result = real(handle, st, path) + if path == self.drop and not os.path.islink(path): + self.swap(path) + return result + + with mock.patch.object(shared_folder, "open_signature", signature_then_swap): + yield + + @contextmanager + def swap_after_check(self): + """Swap drop/sub right after the import's walk checked it, before it is listed.""" + real = builder._check_opened + importing = [] + real_build = builder.build_open + + def build_open(*args, **kwargs): + """builder.build_open, noting that the import (not the signature walk) is running.""" + importing.append(True) + return real_build(*args, **kwargs) + + def check_then_swap(path, expected, handle, follow=False): + """_check_opened, then the swap.""" + real(path, expected, handle, follow) + sub = os.path.join(self.drop, "sub") + if importing and path == sub and not os.path.islink(sub): + self.swap(sub) + + with mock.patch.object(builder, "_check_opened", check_then_swap), \ + mock.patch.object(builder, "build_open", build_open): + yield + + def scan(self, fd_walk: bool, swapping) -> set[Cid]: + """The pins of a scan with builder.FD_WALK set to fd_walk and the swap made by swapping.""" + with mock.patch.object(builder, "FD_WALK", fd_walk), swapping(): + return self.folder.scan() + + def imported_files(self, pins: set[Cid]) -> set[str]: + """Paths of every file in the DAGs of pins, below the pinned directory.""" + get = self.storage.getter() + paths = set() + + def visit(root: Cid, names: list[str]): + """Collect the files below the directory /.""" + for entry in walker.list_directory(walker.resolve(root, names, get), get): + if entry.kind == dag.KIND_DIRECTORY: + visit(root, names + [entry.name]) + else: + paths.add("/".join(names + [entry.name])) + + for cid in pins: + visit(cid, []) + return paths + + def outside_files(self, pins: set[Cid]) -> set[str]: + """Imported files that came from the private directory.""" + return {path for path in self.imported_files(pins) if path.endswith("secret.txt")} + + def assert_leaked(self, pins: set[Cid]): + """The secret was read into storage and is in the folder's pinned (so provided) DAG.""" + self.assertTrue(self.storage.has(Cid.for_block(RAW, SECRET))) + self.assertTrue(self.outside_files(pins)) + self.assertEqual(pins, self.storage.pinned_by(FOLDER)) + + def assert_not_leaked(self, pins: set[Cid]): + """Nothing from outside the folder was read into storage.""" + self.assertFalse(self.storage.has(Cid.for_block(RAW, SECRET)), "the secret's block was stored") + self.assertEqual(self.outside_files(pins), set(), "imported from outside the folder") + + def test_path_walk_imports_outside_files(self): + for swapping in (self.swap_after_signature, self.swap_after_check): + with self.subTest(swapping.__name__): + self.make_folder() + pins = self.scan(False, swapping) + self.assert_leaked(pins) + + @unittest.skipUnless(builder.FD_WALK, "directories opened by descriptor (POSIX)") + def test_descriptor_walk_stays_in_the_folder(self): + for swapping in (self.swap_after_signature, self.swap_after_check): + with self.subTest(swapping.__name__): + self.make_folder() + pins = self.scan(True, swapping) + self.assertTrue(os.path.islink(self.drop) or os.path.islink(os.path.join(self.drop, "sub"))) + self.assert_not_leaked(pins) + + # L8: fails until the path walk re-checks each directory's identity (or walks by handle) so a swapped-in + # junction can't lead outside the folder + @unittest.expectedFailure + def test_path_walk_stays_in_the_folder(self): + for swapping in (self.swap_after_signature, self.swap_after_check): + self.make_folder() + pins = self.scan(False, swapping) + self.assert_not_leaked(pins) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l9_get_free_space.py b/tests/security/test_l9_get_free_space.py new file mode 100644 index 0000000..ea06d4b --- /dev/null +++ b/tests/security/test_l9_get_free_space.py @@ -0,0 +1,64 @@ +"""L9: `get` checks the free space of the output's filesystem without the disk reserve, so a get can fill the disk to +nothing free (the block store keeps the reserve; the write-out doesn't). + +A node gets a file it already holds completely (so nothing is fetched) while the disk, by a mock, has just 1 byte +more free than the file needs: the get goes ahead, although writing the file leaves 1 byte free. The fixed behaviour +refuses a get whose output wouldn't leave the disk reserve free. +""" +import io +import os +import shutil +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE +from rnfs.content import storage as storage_module +from rnfs.errors import StorageFull, TooLarge +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, NetClient + +FILE_SIZE = 3 * CHUNK_SIZE + + +class GetFreeSpaceTest(unittest.TestCase): + """An offline node holding a 192 KiB file, on a 1 GiB disk the test says is nearly full.""" + + def setUp(self): + """The node and its stored file.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + config = Config(rnfs_dir=os.path.join(self.tmp.name, "a"), slow="no", lookup_timeout=1, path_timeout=0.2) + self.node = RnfsNode.offline(config, A_ID, NetClient(), FakeNetwork()) + self.addCleanup(self.node.stop) + self.cid = self.node.add_stream(io.BytesIO(os.urandom(FILE_SIZE)))["cid"] + self.out = os.path.join(self.tmp.name, "out") + + def disk(self, free: int): + """A mock disk_usage reporting free bytes on a 1 GiB filesystem (reserve: DISK_RESERVE_MIN).""" + usage = shutil.disk_usage(self.tmp.name)._replace(total=1024 ** 3, free=free) + return mock.patch("shutil.disk_usage", return_value=usage) + + def test_get_leaves_less_than_the_reserve_free(self): + with self.disk(FILE_SIZE + 1): + self.node.get(self.cid, self.out) + self.assertEqual(os.path.getsize(self.out), FILE_SIZE) # written, leaving 1 byte free + + # L9: fails until get's free-space check requires the size plus the disk reserve + @unittest.expectedFailure + def test_get_keeps_the_disk_reserve_free(self): + with self.disk(FILE_SIZE + storage_module.DISK_RESERVE_MIN - 1): + with self.assertRaises((TooLarge, StorageFull), msg="get would leave less than the reserve free"): + self.node.get(self.cid, self.out) + self.assertFalse(os.path.lexists(self.out)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_n1_user_pin_unbounded.py b/tests/security/test_n1_user_pin_unbounded.py new file mode 100644 index 0000000..04c0ec9 --- /dev/null +++ b/tests/security/test_n1_user_pin_unbounded.py @@ -0,0 +1,48 @@ +"""N1 (core part): a user-path pin (`node.pin_add`, which `rnfs pin add` runs as a job) sets no max_dag_bytes, +unlike a remote /pin or an LXMF auto-fetch, so pinning a root that declares 1 byte downloads and pins its whole DAG. + +B pins a root served by A whose content is b"x" but whose DAG holds about 2 MB of distinct file nodes made only of +empty chunks (as in L3): every block is downloaded and kept under the "user" pin. The fixed behaviour bounds the DAG +bytes of a user pin by a cap derived from the declared size, as the remote /pin path already does with its quota. +""" +import unittest + +from rnfs.content.storage import USER +from rnfs.errors import RnfsError +from tests.security._content_helpers import SLACK, TwoNodeTest + + +class UserPinTest(TwoNodeTest): + """B runs pin_add on a 1-byte file whose DAG is mostly empty file nodes.""" + + def setUp(self): + """A serves the bomb.""" + super().setUp() + self.bomb = self.serve_bomb() + + def test_pin_add_downloads_and_pins_every_empty_file_node(self): + self.assertEqual(self.b.info(str(self.bomb.root))["size"], 1) # what `rnfs stat` and pages check + self.b.pin_add(str(self.bomb.root)) + self.assertIn(USER, self.b.storage.pins()[self.bomb.root]) + self.assertEqual(self.net.downloaded_blocks, len(self.bomb.blocks)) + self.assertEqual(self.net.downloaded_bytes, self.bomb.total_bytes()) + self.assertGreater(self.net.downloaded_bytes, 8 * (self.bomb.size + SLACK)) + # All of it is kept for good, and provided + self.assertEqual(self.b.storage.pinned_bytes(), self.bomb.total_bytes()) + self.assertIn(self.bomb.root, self.b.publisher.provided()) + + # N1: fails until pin_add caps a pin's DAG bytes by a bound derived from the root's declared size + @unittest.expectedFailure + def test_pin_add_dag_bytes_are_bounded_by_the_declared_size(self): + try: + self.b.pin_add(str(self.bomb.root)) + except RnfsError: + pass # refusing the pin is the expected outcome + self.assertLessEqual(self.net.downloaded_bytes, self.bomb.size + SLACK, + f"pin_add of a root declaring {self.bomb.size} byte downloaded " + f"{self.net.downloaded_bytes} bytes in {self.net.downloaded_blocks} blocks") + self.assertLessEqual(self.b.storage.pinned_bytes(), self.bomb.size + SLACK) + + +if __name__ == "__main__": + unittest.main() From a87447499e997107a52a6330d7ba81c035090c0f Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 01:08:29 +1030 Subject: [PATCH 003/107] Add page security regression tests for H2, N1, M6, L10 and L14 --- tests/security/_pages_helpers.py | 187 +++++++++++++++++ tests/security/test_h2_display_page_pins.py | 195 ++++++++++++++++++ .../security/test_l10_index_page_injection.py | 69 +++++++ tests/security/test_l14_page_local_paths.py | 47 +++++ tests/security/test_m6_index_page_names.py | 74 +++++++ .../test_n1_display_page_unbounded.py | 73 +++++++ 6 files changed, 645 insertions(+) create mode 100644 tests/security/_pages_helpers.py create mode 100644 tests/security/test_h2_display_page_pins.py create mode 100644 tests/security/test_l10_index_page_injection.py create mode 100644 tests/security/test_l14_page_local_paths.py create mode 100644 tests/security/test_m6_index_page_names.py create mode 100644 tests/security/test_n1_display_page_unbounded.py diff --git a/tests/security/_pages_helpers.py b/tests/security/_pages_helpers.py new file mode 100644 index 0000000..124e988 --- /dev/null +++ b/tests/security/_pages_helpers.py @@ -0,0 +1,187 @@ +"""Shared harness for the companion NomadNet page findings (not a test module). + +The two pages are Python scripts NomadNet runs as CGI-like pages; they shell out to the ``rnfs`` CLI +(``companion-nomadnet-sites/display-mu-file/index.mu`` and ``.../file-index-node/index.mu``). These helpers +run the pages' own code against an offline ``RnfsNode`` (real node, fetcher, storage and publisher; the +transport is the ``NetClient`` double from ``tests.test_node``). The CLI the pages call is stood in for by a +fake ``subprocess.run`` that drives the in-process node, so the pages' real control flow, escaping and +``entry()`` logic run against real pin data. The slow, full-stack variants (a real daemon driven through the +real CLI) live in the test modules behind ``RNFS_E2E``. +""" +import io +import json +import os +import subprocess +import sys +from unittest import mock + +import RNS + +from rnfs.config import Config +from rnfs.content import dag +from rnfs.content.cid import DAG, EMPTY_FILE, RAW, Cid +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, B_ID, NetClient + +WORKTREE_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PAGES_DIR = os.path.join(WORKTREE_ROOT, "companion-nomadnet-sites") +DISPLAY_PAGE = os.path.join(PAGES_DIR, "display-mu-file", "index.mu") +INDEX_PAGE = os.path.join(PAGES_DIR, "file-index-node", "index.mu") + + +def make_nodes(tmp, settings=None): + """A provider node A and a victim node B joined by a NetClient, as tests.test_node does.""" + net = NetClient() + net.sender = B_ID + nodes = {} + for name, node_id in (("a", A_ID), ("b", B_ID)): + config = Config(rnfs_dir=os.path.join(tmp, name), slow="no", lookup_timeout=5, path_timeout=0.5, + **(settings or {})) + node = RnfsNode.offline(config, node_id, net, FakeNetwork()) + net.nodes[node_id] = node + nodes[name] = node + nodes["b"].routing.heard(A_ID) + return net, nodes["a"], nodes["b"] + + +def give_identity(node): + """Give an offline node an RNS identity, so it can seal to_self (sealed adds).""" + node.identity = RNS.Identity() + return node + + +def advertise(provider, finder, cid): + """Make provider a provider of cid, in both its own records and the finder's, so a lookup finds it.""" + key = Cid.parse(cid).key if isinstance(cid, str) else cid.key + provider.records.store(key, provider.node_id) + finder.records.store(key, provider.node_id) + + +# --- the file-index-node page's own functions, lifted from its source --- + + +def index_page_namespace(): + """The file-index-node page's module-level defs (escape, display_name, entry, section).""" + with open(INDEX_PAGE, encoding="utf-8") as f: + source = f.read() + head = source.split('print("""#!c=0')[0] # everything before the page body + namespace = {} + exec(compile(head, INDEX_PAGE, "exec"), namespace) + return namespace + + +def render_index(node): + """What the file-index-node page's entry() prints for each of node's complete pins (as the page does: + pin_ls JSON, round-tripped, through the page's own entry()).""" + namespace = index_page_namespace() + pins = json.loads(json.dumps(node.pin_ls())) # the CLI's pin ls --json, then the page's json.loads + return [namespace["entry"](pin) for pin in pins if pin.get("complete")] + + +# --- driving the display-mu-file page's whole script against an in-process node --- + + +class _Completed: + """A subprocess.CompletedProcess stand-in: the three attributes the page reads.""" + + def __init__(self, returncode, stdout=b"", stderr=""): + """returncode, stdout bytes and stderr text (as the page's rnfs() expects).""" + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr.encode("utf-8") if isinstance(stderr, str) else stderr + + +def fake_rnfs(node, calls, timeout_on=()): + """A subprocess.run stand-in for the page: it runs the rnfs subcommand against node in-process. + + calls records each argument list the page runs. A subcommand named in timeout_on raises + TimeoutExpired, as the real subprocess.run does when the CLI outlives its timeout. + """ + from rnfs.errors import NotFound + + def run(argv, *args, **kwargs): + """Serve the page's rnfs(...) call from the node.""" + words = list(argv[1:]) # argv[0] is the rnfs executable path + calls.append(words) + if words and words[0] in timeout_on: + raise subprocess.TimeoutExpired(argv, kwargs.get("timeout")) + try: + if words[0] == "stat": + lines = "".join(f"{name}: {value}\n" for name, value in node.stat(words[1]).items()) + return _Completed(0, lines.encode("utf-8")) + if words[0] == "ls": + node.ls(words[1]) + return _Completed(0, b"\n") + if words[:2] == ["pin", "add"]: + return _Completed(0, (node.pin_add(words[2])["cid"] + "\n").encode("utf-8")) + if words[0] == "cat": + return _Completed(0, b"".join(node.read(words[1]))) + if words[:3] == ["pin", "ls", "--json"]: + return _Completed(0, json.dumps(node.pin_ls()).encode("utf-8")) + except ValueError as error: # e.g. " is not a directory", which the page keys off + return _Completed(1, stderr=str(error)) + except NotFound as error: + return _Completed(1, stderr=str(error)) + return _Completed(1, stderr="unknown command") + + return run + + +def exec_display_page(run, cid): + """Run the real display-mu-file script for var_cid=cid with subprocess.run replaced by run; return + whatever the page printed.""" + with open(DISPLAY_PAGE, encoding="utf-8") as f: + source = f.read() + output = io.StringIO() + with mock.patch.object(subprocess, "run", run), \ + mock.patch.dict(os.environ, {"var_cid": cid}), \ + mock.patch.object(sys, "stdout", output): + try: + exec(compile(source, DISPLAY_PAGE, "exec"), {"__name__": "__main__"}) + except SystemExit: + pass # the page stops with SystemExit on every error path + return output.getvalue() + + +def run_display_page(node, cid, timeout_on=()): + """Run the real display-mu-file script for var_cid=cid against node; return (output, calls).""" + calls = [] + output = exec_display_page(fake_rnfs(node, calls, timeout_on), cid) + return output, calls + + +# --- the N1 "1-byte" DAG bomb --- + + +def bomb_dag(fanout=3, children=3000): + """A file root that declares 1 byte but carries fanout large file-node blocks of zero-size children. + + Returns (root_cid, {cid: block}). Each level-1 node lists `children` empty chunks (size 0), so the root's + declared size is 1 (the single b"x" leaf) while the DAG's blocks total far more than that. + """ + blocks = {EMPTY_FILE: b""} + + def store(node): + """Encode node, remember its block, return its CID.""" + block = dag.encode(node) + cid = Cid.for_block(DAG, block) + blocks[cid] = block + return cid + + level1 = [store(dag.FileNode([dag.Child(EMPTY_FILE, 0)] * (children + i))) for i in range(fanout)] + leaf = Cid.for_block(RAW, b"x") + blocks[leaf] = b"x" + root = store(dag.FileNode([dag.Child(cid, 0) for cid in level1] + [dag.Child(leaf, 1)])) + return root, blocks + + +def serve_blocks(provider, finder, root, blocks): + """Store blocks on provider, pin+provide root so provider serves them, and advertise root to finder.""" + with provider.storage.protect() as session: + for cid, block in blocks.items(): + session.put(cid, block) + from rnfs.content.storage import USER + provider.storage.pin(root, USER) + provider.publisher.refresh() + advertise(provider, finder, root) diff --git a/tests/security/test_h2_display_page_pins.py b/tests/security/test_h2_display_page_pins.py new file mode 100644 index 0000000..5620edf --- /dev/null +++ b/tests/security/test_h2_display_page_pins.py @@ -0,0 +1,195 @@ +"""H2: one anonymous view of the display-mu-file page makes the node permanently pin (source "user") and +provide content the visitor chose. The CID comes from var_cid; the page runs stat, ls, `pin add` and cat on +it with no allowlist and no per-visitor limit (index.mu:70-100). `pin add` pins USER (node.py:821-833), which +the node then provides; and with the default provide_strategy="roots", the page's stat/cat alone already make +a cached complete fetch be provided, so dropping only the pin would not stop the seeding. + +The in-process tests run the page's real script against an offline victim node that fetches from an offline +provider (the NetClient double). The job-leak variant (the daemon job surviving the page's SIGKILL) needs the +real daemon/CLI process split and is gated behind RNFS_E2E. +""" +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest + +import RNS + +from rnfs.content.cid import Cid +from rnfs.content.storage import USER +from tests.security import _pages_helpers as helpers + + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class DisplayPagePinTest(unittest.TestCase): + """The display page driven against an in-process victim that can fetch the CID from a provider.""" + + def setUp(self): + """A provider A holding a small file, and a victim B that serves the page.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.provider, self.victim = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.provider.stop) + self.addCleanup(self.victim.stop) + self.cid = self.provider.add_stream(io.BytesIO(b"hello from the visitor's chosen content\n"))["cid"] + helpers.advertise(self.provider, self.victim, self.cid) + self.key = Cid.parse(self.cid) + + @unittest.expectedFailure # H2: fails until the page only pins CIDs the operator allowlisted + def test_page_view_does_not_pin_a_non_allowlisted_cid(self): + """A single page view for a CID the operator never chose must not create a permanent user pin.""" + output, calls = helpers.run_display_page(self.victim, self.cid) + self.assertIn(b"hello from the visitor's chosen content\n", output.encode()) # the page served it + self.assertNotIn(USER, self.victim.storage.pins().get(self.key, set())) + + @unittest.expectedFailure # H2: fails until a page view for a non-allowlisted CID never makes the node provide + def test_page_view_does_not_make_the_node_provide(self): + """After a page view, the node must not advertise itself as a provider of the visitor's CID.""" + helpers.run_display_page(self.victim, self.cid) + self.assertNotIn(self.key, self.victim.publisher.provided()) + + @unittest.expectedFailure # H2: fails until the page's read path (stat/cat) does not provide either + def test_stat_and_cat_alone_make_the_node_provide(self): + """Removing only the `pin add` isn't enough: with provide_strategy="roots", the page's remaining + stat and cat calls make the complete cached fetch be provided.""" + self.victim.stat(self.cid) # the page's `rnfs stat` + b"".join(self.victim.read(self.cid)) # the page's `rnfs cat` + self.assertNotIn(self.key, self.victim.publisher.provided()) + + +# --- full-stack variant: a real daemon, driven through the real CLI, with the page's SIGKILL --- + +E2E = unittest.skipUnless(os.environ.get("RNFS_E2E") == "1", "set RNFS_E2E=1 to run") + + +@E2E +class DisplayPageDaemonTest(unittest.TestCase): + """Run the actual page script as a subprocess against a real victim daemon served by a real provider.""" + + def setUp(self): + """A hub (transport), a provider and a victim daemon over loopback TCP.""" + from tests import test_e2e + self.e2e = test_e2e + self.tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self.tmp.cleanup) + self.daemons = [] + self.addCleanup(self._stop_all) + tcp_port = helpers_free_port() + self.hub = self._start("hub", test_e2e.HUB_CONFIG, tcp_port) + bootstrap = ("--bootstrap", self.hub.node_id) + self.provider = self._start("prov", test_e2e.NODE_CONFIG, tcp_port, *bootstrap) + self.victim = self._start("victim", test_e2e.NODE_CONFIG, tcp_port, *bootstrap) + + def _start(self, name, config, tcp_port, *extra): + """Start one daemon and remember it for cleanup.""" + daemon = self.e2e.Daemon(name, self.tmp.name, config, tcp_port, *extra) + self.daemons.append(daemon) + return daemon + + def _stop_all(self): + """Stop every daemon.""" + for daemon in self.daemons: + daemon.interrupt() + for daemon in self.daemons: + daemon.stop() + + def _shim(self, daemon): + """A directory holding an `rnfs` executable that drives daemon through the worktree's CLI.""" + bindir = os.path.join(self.tmp.name, f"bin-{daemon.name}") + os.makedirs(bindir, exist_ok=True) + shim = os.path.join(bindir, "rnfs") + with open(shim, "w", encoding="utf-8") as f: + f.write(f'#!/bin/sh\nexec "{sys.executable}" "{self.e2e.MAIN}" ' + f'--dir "{daemon.dir}" --api-port {daemon.api_port} "$@"\n') + os.chmod(shim, 0o755) + return bindir + + def _run_page(self, daemon, cid, env_extra=None): + """Run the display-mu-file page as NomadNet would, against daemon, for var_cid=cid.""" + env = {**os.environ, "PATH": self._shim(daemon) + os.pathsep + os.environ.get("PATH", ""), + "var_cid": cid, **(env_extra or {})} + return subprocess.Popen([sys.executable, helpers.DISPLAY_PAGE], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + @unittest.expectedFailure # H2: fails until the page does not permanently pin non-allowlisted CIDs + def test_page_view_pins_as_user_on_a_real_daemon(self): + """The real page, run against a real daemon, leaves a permanent user pin for the visitor's CID.""" + path = os.path.join(self.tmp.name, "content.txt") + with open(path, "wb") as f: + f.write(b"visitor chosen bytes\n") + cid = self.provider.out("add", path) + self.e2e.wait_for(lambda: self.provider.node_id in self._providers(self.victim, cid), 60, + "victim to find the provider") + page = self._run_page(self.victim, cid) + out, err = page.communicate(timeout=180) + self.assertEqual(page.returncode, 0, err.decode("utf-8", "replace")) + self.assertIn(b"visitor chosen bytes", out) + pins = {pin["cid"]: pin for pin in json.loads(self.victim.out("pin", "ls", "--json"))} + self.assertNotIn(cid, pins, "the page left a permanent pin for a visitor-chosen CID") + + @unittest.expectedFailure # H2: fails until killing the page's pin add cancels the daemon job + def test_killed_pin_add_still_pins_on_the_daemon(self): + """The page pins with `rnfs pin add` and SIGKILLs it on timeout; the daemon finishes the pin anyway. + + The page runs `rnfs pin add` as a subprocess and SIGKILLs it when it times out. follow() cancels the + daemon job only on KeyboardInterrupt, so SIGKILL leaves the job running: the daemon fetches and + permanently pins the content even though the page's CLI is gone. Here the CLI is SIGKILLed as soon as + its pin job is running, and the victim still ends up with the CID pinned. + """ + path = os.path.join(self.tmp.name, "big.bin") + with open(path, "wb") as f: + f.write(os.urandom(3 * 1024 * 1024)) # large enough that the fetch is still running when killed + cid = self.provider.out("add", path) + self.e2e.wait_for(lambda: self.provider.node_id in self._providers(self.victim, cid), 60, + "victim to find the provider") + proc = subprocess.Popen( + [sys.executable, self.e2e.MAIN, "--dir", self.victim.dir, "--api-port", str(self.victim.api_port), + "pin", "add", cid], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, **self.e2e.POPEN_OPTIONS) + try: + self.e2e.wait_for(lambda: self._active_targets(self.victim), 30, "the pin job to start running") + proc.kill() # SIGKILL, as subprocess.run(timeout=...) does on timeout + proc.wait(timeout=20) + # No `what`, so a timeout returns False (not pinned) instead of raising + pinned = self.e2e.wait_for( + lambda: cid in [pin["cid"] for pin in json.loads(self.victim.out("pin", "ls", "--json"))], 60) + self.assertFalse(pinned, "the daemon pinned the content after the page's CLI was killed") + finally: + if proc.poll() is None: + proc.kill() + + def _active_targets(self, daemon): + """Targets of the daemon's queued or running jobs.""" + lines = daemon.out("jobs", "list").splitlines() + return [line.split()[-1] for line in lines + if len(line.split()) >= 3 and line.split()[1] in ("queued", "running")] + + def _providers(self, daemon, cid): + """Provider node IDs daemon finds for cid.""" + result = daemon.cli("providers", cid, check=False) + return result.stdout.decode("utf-8", "replace").split() if result.returncode == 0 else [] + + +def helpers_free_port(): + """A free local TCP port (support.free_port, imported lazily so the module loads without RNFS_E2E).""" + from tests.support import free_port + return free_port() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l10_index_page_injection.py b/tests/security/test_l10_index_page_injection.py new file mode 100644 index 0000000..7cdf77e --- /dev/null +++ b/tests/security/test_l10_index_page_injection.py @@ -0,0 +1,69 @@ +"""L10: Micron line injection through DAG entry names on the file-index-node page. The page's escape() only +escapes backslash and backtick (index.mu:28-30), but a directory entry name may contain newlines and other +control characters (dag.check_name allows them). A name with a newline therefore injects whole Micron lines +into the page: a fake ">" heading, or a fake entry line. + +A directory whose single entry name carries such a payload is built directly (decode accepts the name) and +pinned, then rendered through the page's own entry(). The attacker needs a trusted route to get the directory +pinned (an auto-fetch sender, a follow, or a remote pin); the injection itself is what is shown here. +""" +import tempfile +import unittest + +import RNS + +from rnfs.content import dag +from rnfs.content.cid import DAG, RAW, Cid +from rnfs.content.storage import USER +from tests.security import _pages_helpers as helpers + +INJECTED_HEADING = ">Official downloads" + + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class LineInjectionTest(unittest.TestCase): + """A directory entry name that tries to add Micron lines to the page.""" + + def setUp(self): + """One offline node holding the crafted directory.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.node, _ = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.node.stop) + + def pin_crafted_directory(self, entry_name: str) -> str: + """Store and user-pin a one-entry directory whose entry is named entry_name; return its CID.""" + leaf = b"payload" + leaf_cid = Cid.for_block(RAW, leaf) + block = dag.encode(dag.DirectoryNode([dag.Entry(entry_name, leaf_cid, len(leaf), dag.KIND_FILE)])) + cid = Cid.for_block(DAG, block) + with self.node.storage.protect() as session: + session.put(leaf_cid, leaf) + session.put(cid, block) + self.node.storage.pin(cid, USER) + return str(cid) + + @unittest.expectedFailure # L10: fails until escape() also strips newlines / control characters + def test_entry_name_cannot_add_lines(self): + """An entry name must render as a single page line, never inject a heading or a fake entry.""" + self.pin_crafted_directory(f"readme.txt\n{INJECTED_HEADING}\n a second line") + rendered = [line for line in helpers.render_index(self.node) if line] + printed_lines = "\n".join(rendered).splitlines() + # The page must not have produced the attacker's heading as a line of its own + self.assertNotIn(INJECTED_HEADING, printed_lines) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l14_page_local_paths.py b/tests/security/test_l14_page_local_paths.py new file mode 100644 index 0000000..be2c6e8 --- /dev/null +++ b/tests/security/test_l14_page_local_paths.py @@ -0,0 +1,47 @@ +"""L14: the pages show daemon errors and CLI stderr to remote visitors, which can carry local filesystem +paths (for example "no API token at /...: is the daemon running"). The display-mu-file page echoes +the CLI's stderr verbatim on every error path (index.mu:54, 66, 86, 90, 94). + +This runs the page's real script with a CLI that fails exactly as rnfs does when the daemon is down, and +checks the visitor is not shown the local path the error carries. +""" +import os +import unittest + +import RNS + +from tests.security import _pages_helpers as helpers + +_saved_loglevel = RNS.loglevel +# A stderr line the real CLI prints when it can't reach the daemon (cli.py:84-88), carrying a local path. +RNFS_DIR = os.path.join(os.path.expanduser("~"), ".config", "rnfs") +DAEMON_DOWN = f"rnfs: no API token at {RNFS_DIR}/apitoken: is the daemon running?" +CID = "bafkreiezrecqkty6ertxgtpi4mt6ns733iqnb2bfqwbiv6bk4b3lky4n4e" + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class LocalPathLeakTest(unittest.TestCase): + """The display page run against a CLI whose stderr carries a local path.""" + + @unittest.expectedFailure # L14: fails until the page shows a generic message and logs the detail locally + def test_display_page_hides_cli_stderr_paths(self): + """A failing `rnfs stat` must not leak its local path to the visitor.""" + def run(argv, *args, **kwargs): + """Fail as the CLI does when the daemon is down.""" + return helpers._Completed(1, stderr=DAEMON_DOWN) + + output = helpers.exec_display_page(run, CID) + self.assertNotIn(RNFS_DIR, output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_m6_index_page_names.py b/tests/security/test_m6_index_page_names.py new file mode 100644 index 0000000..75a8263 --- /dev/null +++ b/tests/security/test_m6_index_page_names.py @@ -0,0 +1,74 @@ +"""M6: the file-index-node page publishes sealed files' plaintext names and pins that didn't come from the +operator. Spec section 5 promises a sealed file's name stays confidential, yet a sealed add records its +plaintext path/seal_name (node.py:650, 669) and the page prints the basename to every visitor; a directory +pinned from a contact (remote:/lxmf:/follow:) always gets a name from _pin_shape, so it is shown too. + +The page's own entry() is run over the real pin_ls() of an offline node (JSON round-tripped, as the page +does). The recheck notes SHOW_UNNAMED=False hides unnamed file pins, so the assertions are precise: they +target sealed-file names and a named directory pin from a non-operator source. +""" +import io +import os +import tempfile +import unittest + +import RNS + +from rnfs.content.cid import Cid +from tests.security import _pages_helpers as helpers + + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class SealedNamesTest(unittest.TestCase): + """Sealed adds and contact-sourced directory pins, rendered through the page's entry().""" + + def setUp(self): + """One offline node with an identity (so it can seal to itself).""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.node, _ = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.node.stop) + helpers.give_identity(self.node) + + def page(self) -> str: + """The page body the file-index-node script would print for this node's pins.""" + return "\n".join(line for line in helpers.render_index(self.node) if line) + + @unittest.expectedFailure # M6: fails until the page omits (or redacts) the names of sealed files + def test_sealed_file_names_are_not_published(self): + """A sealed file's plaintext name must never reach the page.""" + path = os.path.join(self.tmp.name, "Q3-layoffs.xlsx") + with open(path, "wb") as f: + f.write(b"secret data" * 10) + self.node.add_path(path, to_self=True) + self.node.add_stream(io.BytesIO(b"minutes"), to_self=True, seal_name="board-minutes.txt") + page = self.page() + self.assertNotIn("Q3-layoffs.xlsx", page) + self.assertNotIn("board-minutes.txt", page) + + @unittest.expectedFailure # M6: fails until the page lists only operator (user/folder) pins + def test_pins_from_contacts_are_not_published(self): + """A directory pinned from a contact (a remote: source), not by the operator, must not be listed.""" + folder = os.path.join(self.tmp.name, "from-a-contact") + os.makedirs(folder) + with open(os.path.join(folder, "offer-letters"), "wb") as f: + f.write(b"x") + cid = self.node.add_path(folder, pin=False)["cid"] # cached, not a user pin, no recorded name + self.node.storage.pin(Cid.parse(cid), "remote:" + "ab" * 16) # a contact asked us to pin it + self.assertNotIn("offer-letters", self.page()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_n1_display_page_unbounded.py b/tests/security/test_n1_display_page_unbounded.py new file mode 100644 index 0000000..8de9058 --- /dev/null +++ b/tests/security/test_n1_display_page_unbounded.py @@ -0,0 +1,73 @@ +"""N1 (the H2 + L3 chain): one anonymous view of the display-mu-file page makes the node download and +permanently pin an unbounded DAG, bypassing the page's 200 KB MAX_SIZE check. A file root may truthfully +declare 1 byte while carrying many zero-size children (L3: no reader rejects size-0 file-node children). The +page's size_of() reads that declared 1 byte, so the MAX_SIZE check passes (index.mu:76-80); `pin add` then +fetches the whole DAG with no max_dag_bytes and pins it USER (node.py:821-826), bounded only by the disk +reserve. + +The test runs the page's real script against an offline victim that fetches the crafted DAG from an offline +provider, and asserts the node did not store far more than MAX_SIZE. The real-daemon variant is gated behind +RNFS_E2E. +""" +import tempfile +import unittest + +import RNS + +from rnfs.constants import MAX_BLOCK_SIZE +from rnfs.content.storage import USER +from tests.security import _pages_helpers as helpers + +MAX_SIZE = 200 * 1024 # display-mu-file index.mu:18 +OVERHEAD = MAX_BLOCK_SIZE # generous per-view slack: even one extra full block stays well under the bomb + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class UnboundedPinTest(unittest.TestCase): + """A '1-byte' file root that carries far more than MAX_SIZE of DAG blocks.""" + + def setUp(self): + """A provider serving the bomb DAG and a victim that serves the page.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.provider, self.victim = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.provider.stop) + self.addCleanup(self.victim.stop) + self.root, blocks = helpers.bomb_dag(fanout=5, children=3000) + helpers.serve_blocks(self.provider, self.victim, self.root, blocks) + + @unittest.expectedFailure # N1: fails until a page view can't store more than MAX_SIZE (+ overhead) of DAG + def test_one_page_view_cannot_store_more_than_max_size(self): + """The page admits the 1-byte-declared root, then pins a DAG many times larger than MAX_SIZE.""" + declared = self.victim.stat(str(self.root))["size"] + self.assertEqual(declared, 1) # what the page's MAX_SIZE check sees + + _, calls = helpers.run_display_page(self.victim, str(self.root)) + self.assertIn(["pin", "add", str(self.root)], calls) # the MAX_SIZE check did not stop it + + stored = self.victim.storage.used_bytes() + self.assertLessEqual( + stored, MAX_SIZE + OVERHEAD, + f"a single page view of a 1-byte-declared root stored {stored} bytes " + f"in {self.victim.storage.block_count()} blocks") + + @unittest.expectedFailure # N1: fails until the node does not permanently pin an understated DAG + def test_bomb_is_not_permanently_pinned(self): + """The whole oversized DAG is pinned with source user (permanent), as the page's pin add asks.""" + helpers.run_display_page(self.victim, str(self.root)) + self.assertNotIn(USER, self.victim.storage.pins().get(self.root, set())) + + +if __name__ == "__main__": + unittest.main() From eef2c11dabfe7a524eecb13bd91ec3809f22f64b Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 01:32:06 +1030 Subject: [PATCH 004/107] Add a real-RNS regression test for the H1 /blocks slot-hold denial of service --- tests/security/_h1_real_helpers.py | 232 +++++++++++++++++++++++++++++ tests/security/test_h1_real_rns.py | 146 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 tests/security/_h1_real_helpers.py create mode 100644 tests/security/test_h1_real_rns.py diff --git a/tests/security/_h1_real_helpers.py b/tests/security/_h1_real_helpers.py new file mode 100644 index 0000000..0f82360 --- /dev/null +++ b/tests/security/_h1_real_helpers.py @@ -0,0 +1,232 @@ +"""Helpers for test_h1_real_rns: a loopback TCP hub, an H1 attacker, and an honest requester, each run +as its own process over real RNS 1.5.6. + +This module is both a library (config templates, process orchestration) and a small CLI dispatched by role +(hub | attacker | honest), so the test can spawn separate OS processes -- H1 is about one node refusing a +*different* process's /blocks requests, so it can only be shown across real processes, not in one interpreter. + +Nothing here touches rnfs/ code. The attacker and honest clients speak the same /blocks wire format rnfs's +own client sends (rnfs/network/client.py), using rnfs only for its pure protocol constants. +""" +import json +import os +import socket +import sys +import threading +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +MAIN = os.path.join(ROOT, "rnfs.py") + +HUB_CONFIG = """[reticulum] + enable_transport = Yes + share_instance = No + panic_on_interface_error = No +[logging] + loglevel = 3 +[interfaces] + [[TCP Server]] + type = TCPServerInterface + enabled = yes + listen_ip = 127.0.0.1 + listen_port = {port} +""" + +CLIENT_CONFIG = """[reticulum] + enable_transport = No + share_instance = No + panic_on_interface_error = No +[logging] + loglevel = 3 +[interfaces] + [[TCP Client]] + type = TCPClientInterface + enabled = yes + target_host = 127.0.0.1 + target_port = {port} +""" + +# Default rnfs config except for short intervals so the node starts and announces quickly; the H1 knobs +# (max_blocks_responses = 4, deadline_slack = 30, unmeasured_bitrate = 100) are all left at their defaults. +VICTIM_TOML = """default_bootstrap = false +announce_interval = 10 +startup_announce_delay = 2 +scan_interval = 3 +republish_interval = 600 +path_timeout = 10 +lookup_timeout = 30 +""" + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def write(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +# --- raw RNS client helpers (run inside the attacker and honest processes) --- + +def _rns(): + import RNS + return RNS + + +def resolve(node_id_hex, timeout=60.0): + """Request a path to the victim's rnfs.dht destination and build an OUT destination to it.""" + RNS = _rns() + from rnfs.constants import APP_NAME, DHT_ASPECT + node_id = bytes.fromhex(node_id_hex) + deadline = time.monotonic() + timeout + identity = RNS.Identity.recall(node_id) + i = 0 + while identity is None and time.monotonic() < deadline: + if i % 15 == 0: # a path can be cached before its identity is recalled; re-request to pull it + RNS.Transport.request_path(node_id) + time.sleep(0.2) + i += 1 + identity = RNS.Identity.recall(node_id) + if identity is None: + raise TimeoutError(f"no path/identity for {node_id_hex}") + dest = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, DHT_ASPECT) + if dest.hash != node_id: + raise ValueError("recalled identity's rnfs.dht is not the node ID asked for") + return dest + + +def open_link(dest, timeout=30.0, attempts=3): + """Open a real RNS link and wait until ACTIVE, retrying a few times over a busy loopback hub.""" + RNS = _rns() + for _ in range(attempts): + up = threading.Event() + link = RNS.Link(dest, established_callback=lambda _l: up.set()) + up.wait(timeout) + if link.status == RNS.Link.ACTIVE: + return link + try: + link.teardown() + except Exception: + pass + raise TimeoutError("link did not become ACTIVE") + + +def inflate_rtt(link, seconds): + """Make the victim's link.rtt large (its ADVERTISED-Resource timeout is link.rtt * 6), by sending it an + LRRTT packet with an inflated value: the victim does link.rtt = max(measured_rtt, value).""" + RNS = _rns() + from RNS.vendor import umsgpack + RNS.Packet(link, umsgpack.packb(float(seconds)), context=RNS.Packet.LRRTT).send() + + +def send_blocks(link, cid_binaries, max_bytes, timeout=8.0, wait=True): + """Send one /blocks request (the same shape rnfs's client sends) and return its outcome holder.""" + from rnfs.constants import BLOCKS_PATH + state = {"response": None, "done": threading.Event()} + + def on_response(receipt): + state["response"] = receipt.get_response() + state["done"].set() + + def on_failed(receipt): + state["done"].set() + + receipt = link.request(BLOCKS_PATH, [int(max_bytes), list(cid_binaries)], + response_callback=on_response, failed_callback=on_failed, timeout=timeout) + if receipt is None: + state["done"].set() + if wait: + state["done"].wait(timeout) + return state["response"] + + +def cid_binary(cid_text): + from rnfs.content.cid import Cid + return Cid.parse(cid_text).binary + + +# --- role entry points (each becomes its own OS process) --- + +def role_hub(configdir): + RNS = _rns() + RNS.Reticulum(configdir=os.path.abspath(configdir)) + while True: + time.sleep(3600) + + +def role_attacker(configdir, node_id_hex, cid, n_links=8, rtt_seconds=3600.0, poll=0.03): + """Hold the victim's /blocks slots: never download the advertised responses, inflate rtt, keep firing.""" + RNS = _rns() + RNS.Resource.accept = staticmethod(lambda *a, **k: None) # never download a response Resource + RNS.Reticulum(configdir=os.path.abspath(configdir)) + dest = resolve(node_id_hex) + cids = [cid_binary(cid)] * 15 # duplicates -> a ~960 KiB response, sent as a held Resource + links = [] + for _ in range(int(n_links)): + link = open_link(dest) + inflate_rtt(link, rtt_seconds) + links.append(link) + print("ATTACKER_READY", flush=True) + + def hammer(link): + while True: + try: + send_blocks(link, cids, 1 << 40, timeout=2.0, wait=False) + except Exception: + pass + time.sleep(poll) + + for link in links: + threading.Thread(target=hammer, args=(link,), daemon=True).start() + while True: + time.sleep(3600) + + +def role_honest(configdir, node_id_hex, cid, duration, interval, out_json): + """Issue honest /blocks requests and record served / refused (victim answered False) / failed.""" + RNS = _rns() + RNS.Reticulum(configdir=os.path.abspath(configdir)) + dest = resolve(node_id_hex) + cidb = cid_binary(cid) + counts = {"served": 0, "refused": 0, "failed": 0} + link = open_link(dest) + end = time.time() + float(duration) + while time.time() < end: + if link.status != RNS.Link.ACTIVE: + link = open_link(dest) + response = send_blocks(link, [cidb], 64 * 1024, timeout=8.0) + if response is False: + counts["refused"] += 1 + elif isinstance(response, list): + counts["served"] += 1 + else: + counts["failed"] += 1 + time.sleep(float(interval)) + total = sum(counts.values()) + result = {**counts, "total": total, "refusal_rate": counts["refused"] / total if total else 0.0} + with open(out_json, "w") as f: + json.dump(result, f) + print("HONEST_DONE", json.dumps(result), flush=True) + + +def _main(): + role = sys.argv[1] + if role == "hub": + role_hub(sys.argv[2]) + elif role == "attacker": + role_attacker(sys.argv[2], sys.argv[3], sys.argv[4]) + elif role == "honest": + role_honest(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7]) + else: + raise SystemExit(f"unknown role {role}") + + +if __name__ == "__main__": + _main() diff --git a/tests/security/test_h1_real_rns.py b/tests/security/test_h1_real_rns.py new file mode 100644 index 0000000..024b19f --- /dev/null +++ b/tests/security/test_h1_real_rns.py @@ -0,0 +1,146 @@ +"""H1: one peer can hold every /blocks response slot, so honest requesters are refused. + +Reproduced over a REAL Reticulum network (RNS 1.5.6) between separate OS processes: a loopback TCP hub, a +real rnfs daemon serving one public 64 KiB CID with the default config, an attacker process that holds the +response slots, and an honest requester process issuing the same /blocks requests rnfs's own client sends. + +The attacker opens a handful of links, inflates each link's rtt (so a response it never downloads stays +ADVERTISED, holding its slot, since RNS times an advertised Resource out after link.rtt * 6), and keeps +firing /blocks for 15 copies of the CID (a ~960 KiB Resource -- duplicates are kept, see L2). With only two +fast-pool slots by default (max_blocks_responses = 4), both fill and every honest request is refused with +"every fast-link response slot is busy". + +This test asserts the SAFE behaviour -- that an honest requester keeps being served while the attacker runs +-- so it fails until H1 is fixed. See @unittest.expectedFailure below. + +Opt in with RNFS_E2E=1 (real daemons and RNS networking), like tests/test_e2e.py. +""" +import json +import os +import signal +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request + +from tests import support +from tests.security import _h1_real_helpers as helpers + +HELPER = helpers.__file__ +ROOT = helpers.ROOT +MAIN = helpers.MAIN +# H1 is a timing race the attacker wins by polling; the safe refusal rate is near 0, the attacked one near 1. +SAFE_REFUSAL_RATE = 0.3 + + +@unittest.skipUnless(os.environ.get("RNFS_E2E") == "1", "set RNFS_E2E=1 to run") +class H1RealRnsTest(unittest.TestCase): + """A real rnfs daemon's /blocks service is denied to an honest process by one attacker process.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self.tmp.cleanup) + self.base = self.tmp.name + self.procs = [] + self.logs = [] + self.addCleanup(self._stop_all) + + tcp_port = helpers.free_port() + # Hub: a plain RNS transport node. + hub_dir = os.path.join(self.base, "hub") + helpers.write(os.path.join(hub_dir, "config"), helpers.HUB_CONFIG.format(port=tcp_port)) + self._spawn([sys.executable, HELPER, "hub", hub_dir], "hub") + time.sleep(3) + + # Victim: a real rnfs daemon with the default config, serving one public 64 KiB CID. + self.victim_dir = os.path.join(self.base, "victim") + helpers.write(os.path.join(self.victim_dir, "config"), helpers.CLIENT_CONFIG.format(port=tcp_port)) + helpers.write(os.path.join(self.victim_dir, "rnfs.toml"), helpers.VICTIM_TOML) + self.api_port = helpers.free_port() + gw_port = helpers.free_port() + self._spawn([sys.executable, MAIN, "--dir", self.victim_dir, "--api-port", str(self.api_port), + "daemon", "--rnsconfig", self.victim_dir, "--gateway-port", str(gw_port)], "victim", + env={**os.environ, "PYTHONUNBUFFERED": "1"}) + self.node_id = support.wait_for(self._status, 60, "victim daemon to start")["node_id"] + + blob = os.path.join(self.base, "public.bin") + with open(blob, "wb") as f: + f.write(os.urandom(64 * 1024)) + self.cid = subprocess.run([sys.executable, MAIN, "--dir", self.victim_dir, "--api-port", + str(self.api_port), "add", blob], + capture_output=True, timeout=60).stdout.decode().strip() + self.tcp_port = tcp_port + + def _spawn(self, argv, name, env=None): + log = open(os.path.join(self.base, f"{name}.log"), "wb") + self.logs.append(log) + proc = subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT, env=env) + self.procs.append(proc) + return proc + + def _status(self): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{self.api_port}/api/status", timeout=2) as r: + return json.load(r) + except OSError: + return None + + def _stop_all(self): + for p in self.procs: + if p.poll() is None: + p.send_signal(signal.SIGINT) + time.sleep(2) + for p in self.procs: + if p.poll() is None: + p.kill() + for log in self.logs: + try: + log.close() + except Exception: + pass + + def _honest(self, name, duration, interval=1.0): + """Run an honest requester process for duration seconds; return its outcome counts.""" + cfg_dir = os.path.join(self.base, name) + helpers.write(os.path.join(cfg_dir, "config"), helpers.CLIENT_CONFIG.format(port=self.tcp_port)) + out = os.path.join(self.base, f"{name}.json") + with open(os.path.join(self.base, f"{name}.log"), "wb") as log: + subprocess.run([sys.executable, HELPER, "honest", cfg_dir, self.node_id, self.cid, + str(duration), str(interval), out], timeout=duration + 120, check=True, + stdout=log, stderr=subprocess.STDOUT) + with open(out) as f: + return json.load(f) + + @unittest.expectedFailure # H1: fails until the victim stops letting one peer hold every /blocks slot + def test_one_peer_denies_the_blocks_service(self): + # Baseline: honest alone is always served. + baseline = self._honest("honest_baseline", 6) + self.assertEqual(baseline["refused"], 0, f"baseline should never be refused: {baseline}") + self.assertGreater(baseline["served"], 0, f"baseline should serve blocks: {baseline}") + + # Attacker: hold the slots. + atk_dir = os.path.join(self.base, "attacker") + helpers.write(os.path.join(atk_dir, "config"), helpers.CLIENT_CONFIG.format(port=self.tcp_port)) + atk_log = os.path.join(self.base, "attacker.log") + self._spawn([sys.executable, HELPER, "attacker", atk_dir, self.node_id, self.cid], "attacker", + env={**os.environ, "PYTHONUNBUFFERED": "1"}) + + def ready(): + if not os.path.exists(atk_log): + return False + with open(atk_log, "rb") as f: + return b"ATTACKER_READY" in f.read() + support.wait_for(ready, 90, "attacker to hold the slots", interval=0.5) + time.sleep(8) # let the held responses settle into both fast-pool slots + + # Safe behaviour: the honest requester should still be served. It is not -- H1. + attack = self._honest("honest_attack", 20) + self.assertLessEqual( + attack["refusal_rate"], SAFE_REFUSAL_RATE, + f"H1: one attacker denied the /blocks service to an honest peer: {attack}") + + +if __name__ == "__main__": + unittest.main() From 4acf0e62cad0c050351dbeb6e0b9a8000183cc28 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 01:39:55 +1030 Subject: [PATCH 005/107] Add regression tests for keeper discovery, provider admission and name table rollback --- tests/security/test_l4_name_rollback.py | 73 ++++++++ tests/security/test_m3_keeper_discovery.py | 168 +++++++++++++++++++ tests/security/test_m4_provider_admission.py | 114 +++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 tests/security/test_l4_name_rollback.py create mode 100644 tests/security/test_m3_keeper_discovery.py create mode 100644 tests/security/test_m4_provider_admission.py diff --git a/tests/security/test_l4_name_rollback.py b/tests/security/test_l4_name_rollback.py new file mode 100644 index 0000000..be4a50f --- /dev/null +++ b/tests/security/test_l4_name_rollback.py @@ -0,0 +1,73 @@ +"""L4: announces of many other names push a gateway-resolved name out of the bounded name table, after which a +replayed, validly signed older announce of it is accepted and a gateway resolve rolls back to it. + +A NameManager over FakeNetwork resolves name N for the gateway (persist=False) at sequence 5. Then fresh names, as many +as the default name_table_size (10,000; about 0.35 s here), are announced, and N's older announce at sequence 3 is +replayed. The next gateway resolve must not return less than 5. A control shows the replay is refused when no flood +has evicted N. +""" +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.content.cid import RAW, Cid +from rnfs.names import NameManager, encode_record +from tests.support import FakeClock +from tests.test_names import FakeNetwork, FakePins + +N = bytes([0x4E] * 16) +OLD = Cid.for_block(RAW, b"old") +NEW = Cid.for_block(RAW, b"new") + + +def flood_name(i: int) -> bytes: + """The i-th name of the flood, never N.""" + return (0x10 ** 30 + i).to_bytes(16, "big") + + +class GatewayRollbackTest(unittest.TestCase): + """A started NameManager, with no keepers, that has resolved N at sequence 5 for the gateway.""" + + def setUp(self): + """The manager, N's current announce heard, and one gateway resolve of it.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.config = Config(rnfs_dir=tmp.name, path_timeout=0.05) + self.assertEqual(self.config.name_table_size, 10_000) + self.network = FakeNetwork() + self.clock = FakeClock(1_000_000.0) + self.manager = NameManager(self.config, FakePins(), network=self.network, clock=self.clock, + monotonic=self.clock) + self.manager.start() + self.addCleanup(self.manager.stop) + self.network.announce(N, encode_record(5, NEW), self.clock.now) + self.assertEqual(self.manager.resolve(N, persist=False)["sequence"], 5) + + def replay_old(self): + """N's older announce, validly signed, arrives again.""" + self.clock.now += 10 + self.network.announce(N, encode_record(3, OLD), self.clock.now) + + def test_control_replay_refused_without_flood(self): + self.replay_old() + result = self.manager.resolve(N, persist=False) + self.assertEqual((result["sequence"], result["cid"]), (5, str(NEW))) + + # L4: fails until announces only update names this node resolved or keeps, or a floor survives eviction + @unittest.expectedFailure + def test_no_rollback_after_table_churn(self): + for i in range(self.config.name_table_size): + self.clock.now += 0.01 + self.network.announce(flood_name(i), encode_record(1, OLD), self.clock.now) + self.replay_old() + result = self.manager.resolve(N, persist=False) + self.assertGreaterEqual(result["sequence"], 5, "gateway resolve went back from sequence 5") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_m3_keeper_discovery.py b/tests/security/test_m3_keeper_discovery.py new file mode 100644 index 0000000..fb1bcf2 --- /dev/null +++ b/tests/security/test_m3_keeper_discovery.py @@ -0,0 +1,168 @@ +"""M3: identities that stored provider records for a name's keeper key before the real keepers did take every slot +a keeper-fallback resolve asks, so the real keepers are never reached. + +Offline nodes over KeeperNet: owner A publishes a name and advertises it, B resolves it, C keeps it and advertises +that, D is one more DHT server. Squatter identities (never online, so they answer nothing) store provider records for +name_key(N) after A's but before C's, either on B itself or on the servers B's lookup queries (B then holds none). +A goes away and B resolves: the path request hears nothing, so B falls back to keepers. The tests check the outcome +only: B gets C's record, and C was asked. With alpha 3 two squatters are enough; on a slow node (alpha 1) one is. +""" +import io +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.errors import NotFound +from rnfs.features.keepers import name_key +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_names_keep import KeeperNet + +A_ID, B_ID, C_ID, D_ID = (bytes([x] * 16) for x in (0xA, 0xB, 0xC, 0xD)) +SQUATTERS = [bytes([0xE0 + i] * 16) for i in range(3)] # identities with no node behind them + + +class KeeperDiscoveryTest(unittest.TestCase): + """Owner A, resolver B, keeper C and server D as offline nodes that all know each other.""" + + def setUp(self): + """A temporary directory and the network; nodes are made by build().""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dir = tmp.name + self.net = KeeperNet() + + def make_node(self, label: str, node_id: bytes, slow: bool = False) -> RnfsNode: + """An offline node with its own directory.""" + config = Config(rnfs_dir=os.path.join(self.dir, label), slow="no", lookup_timeout=5, path_timeout=0.2) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork(), slow=slow) + self.net.nodes[node_id] = node + self.addCleanup(node.stop) + return node + + def build(self, slow_resolver: bool = False): + """The four nodes, and A's name published and advertised (A's record is the oldest for its key).""" + self.a = self.make_node("a", A_ID) + self.b = self.make_node("b", B_ID, slow=slow_resolver) + self.c = self.make_node("c", C_ID) + self.d = self.make_node("d", D_ID) + ids = (A_ID, B_ID, C_ID, D_ID) + for node, node_id in zip((self.a, self.b, self.c, self.d), ids): + for other in ids: + if other != node_id: + node.routing.heard(other) + self.name = bytes.fromhex(self.a.name_create("site")["name"]) + self.key = name_key(self.name) + self.cid = self.a.add_stream(io.BytesIO(b"v1"))["cid"] + self.a.name_publish("site", self.cid) + self.net.sender = A_ID + self.a.publisher.publish_due() + + def squat(self, nodes, squatters): + """Each squatter stores a provider record for the name's keeper key on each of nodes, as /provide would.""" + for node in nodes: + for squatter in squatters: + self.assertTrue(node.records.store(self.key, squatter)) + + def keep_on_c(self): + """C keeps the name (fetching its record from A) and advertises it.""" + keeper = self.c.feature("keepers") + keeper.keep(self.name) + keeper.run_pending() + self.assertEqual(str(keeper.store.get(self.name).cid), self.cid) + self.net.sender = C_ID + self.c.publisher.publish_due() + + def forget_owner_record(self): + """A's provider records for the key are gone everywhere, as once A has been away longer than their TTL.""" + for node in (self.b, self.c, self.d): + node.records.withdraw(self.key, A_ID) + + def forget_at_resolver(self): + """B holds no provider records for the key, so its resolve has to look them up on the servers.""" + for provider in self.b.records.providers(self.key): + self.b.records.withdraw(self.key, provider) + + def resolve_without_owner(self): + """A goes away; B resolves its name. The result, or None if NotFound.""" + del self.net.nodes[A_ID] + self.net.name_requests.clear() + try: + return self.b.name_resolve(self.name.hex()) + except NotFound: + return None + + def assert_reached_keeper(self, result): + """B got C's record, and asked C for it.""" + asked = [node_id.hex()[:4] for node_id in self.net.name_requests] + self.assertTrue(C_ID in self.net.name_requests, f"keeper C never asked; asked {asked}") + self.assertIsNotNone(result, f"resolve failed; asked {asked}") + self.assertEqual((result["cid"], result["sequence"]), (self.cid, 1)) + + def test_control_keeper_asked_without_squatters(self): + self.build() + self.keep_on_c() + self.assert_reached_keeper(self.resolve_without_owner()) + + def test_control_slow_resolver_asks_keeper_without_squatters(self): + self.build(slow_resolver=True) + self.assertEqual(self.b.config.effective_alpha(self.b.slow), 1) + self.keep_on_c() + self.forget_owner_record() + self.assert_reached_keeper(self.resolve_without_owner()) + + # M3: fails until ask_keepers treats local records as candidates and keeps looking past non-answering ones + @unittest.expectedFailure + def test_squatters_at_resolver_do_not_hide_keeper(self): + self.build() + self.squat([self.b], SQUATTERS[:2]) + self.keep_on_c() + self.assert_reached_keeper(self.resolve_without_owner()) + + # M3: fails until the provider search runs past the first alpha providers found and ranks them by more than age + @unittest.expectedFailure + def test_squatters_at_servers_do_not_hide_keeper(self): + self.build() + self.squat([self.c, self.d], SQUATTERS[:2]) + self.keep_on_c() + self.forget_at_resolver() + self.assert_reached_keeper(self.resolve_without_owner()) + + # M3: fails until ask_keepers treats local records as candidates and keeps looking past non-answering ones + @unittest.expectedFailure + def test_one_squatter_at_slow_resolver_does_not_hide_keeper(self): + self.build(slow_resolver=True) + self.assertEqual(self.b.config.effective_alpha(self.b.slow), 1) + self.squat([self.b], SQUATTERS[:1]) + self.keep_on_c() + self.forget_owner_record() + self.assert_reached_keeper(self.resolve_without_owner()) + + # M3: fails until the provider search runs past the first alpha providers found and ranks them by more than age + @unittest.expectedFailure + def test_one_squatter_at_servers_does_not_hide_keeper_from_slow_resolver(self): + self.build(slow_resolver=True) + self.assertEqual(self.b.config.effective_alpha(self.b.slow), 1) + self.squat([self.c, self.d], SQUATTERS[:1]) + self.keep_on_c() + self.forget_owner_record() + self.forget_at_resolver() + self.assert_reached_keeper(self.resolve_without_owner()) + + # M3: fails until a resolve whose keepers all answer nothing asks a further batch + @unittest.expectedFailure + def test_owners_stale_record_does_not_hide_keeper_from_slow_resolver(self): + """No squatter at all: at alpha 1 the gone owner's own record, the oldest, takes the only slot.""" + self.build(slow_resolver=True) + self.keep_on_c() + self.assert_reached_keeper(self.resolve_without_owner()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_m4_provider_admission.py b/tests/security/test_m4_provider_admission.py new file mode 100644 index 0000000..a5b4797 --- /dev/null +++ b/tests/security/test_m4_provider_admission.py @@ -0,0 +1,114 @@ +"""M4: a provider store filled by about 100 publishers refuses every publisher it hasn't seen yet, this node included. + +A ProviderStore of the default size (Config().max_records) is filled to the brim by 100 identities storing their +per-publisher maximum each. A publisher that stored before the flood is still accepted; a new one, and the node's +own ID with no records yet, are refused. Passing tests pin down today's limits (the per-publisher cap, and how many +keys one /provide request may carry on the server side), which the fix may change. +""" +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.constants import ID_LENGTH, MAX_REQUEST_SIZE, PROVIDE_PATH +from rnfs.dht.providers import ProviderStore +from rnfs.network import protocol, server +from rnfs.network.identity import node_id_for_identity +from tests.support import FakeClock +from tests.test_server import FakeBackend, FakeDestination, FakeLink + +OWN = bytes([0x01] * ID_LENGTH) +HONEST_OLD = bytes([0x02] * ID_LENGTH) +NEWCOMER = bytes([0x03] * ID_LENGTH) +FLOODERS = 100 + + +def ident(n: int) -> bytes: + """The 16-byte ID whose big-endian value is n.""" + return n.to_bytes(ID_LENGTH, "big") + + +class FullStoreTest(unittest.TestCase): + """A default-sized store: one honest publisher, then FLOODERS identities filling it.""" + + def setUp(self): + """The store, filled; every flooder stores its maximum, a second after the one before.""" + self.clock = FakeClock(1_700_000_000.0) + self.store = ProviderStore(Config().max_records, None, clock=self.clock, own_id=OWN) + self.assertTrue(self.store.store(ident(1), HONEST_OLD)) + self.clock.now += 1 + key = 1 << 64 + for flooder in range(FLOODERS): + publisher = ident((0xF << 120) + flooder) + for _ in range(self.store.max_per_publisher): + key += 1 + self.store.store(ident(key), publisher) + self.clock.now += 1 + self.assertEqual(self.store.record_count(), self.store.max_records) + + def test_flooders_needed_to_fill(self): + """Today 100 publishers at 1% each fill the default store of 100,000 records.""" + self.assertEqual((self.store.max_records, self.store.max_per_publisher), (100_000, 1000)) + self.assertEqual(self.store.publisher_count(), FLOODERS + 1) + + def test_established_publisher_accepted(self): + self.assertTrue(self.store.store(ident(2), HONEST_OLD)) + self.assertEqual(self.store.record_count(), self.store.max_records) + + # M4: fails until a full store keeps room for newcomers (a newcomer pool, or an allowance that grows with age) + @unittest.expectedFailure + def test_new_publisher_can_store(self): + accepted = sum(self.store.store(ident(10 + i), NEWCOMER) for i in range(10)) + self.assertGreater(accepted, 0, "a new honest publisher stored none of 10 records in the full store") + + # M4: fails until the node's own records are admitted to its full store whatever its first-seen time + @unittest.expectedFailure + def test_own_records_always_accepted(self): + self.assertIsNone(self.store.first_seen(OWN)) + self.assertTrue(self.store.store(ident(3), OWN), "the node's own record was refused by its full store") + + +class PerPublisherCapTest(unittest.TestCase): + """Today's per-publisher cap: max(10, max_records // 100).""" + + def test_cap(self): + for max_records, cap in ((100, 10), (999, 10), (1000, 10), (5000, 50), (100_000, 1000)): + with self.subTest(max_records=max_records): + self.assertEqual(ProviderStore(max_records, None).max_per_publisher, cap) + + def test_cap_enforced(self): + store = ProviderStore(100_000, None, clock=FakeClock(1_700_000_000.0)) + publisher = ident(7) + self.assertEqual(sum(store.store(ident(100 + i), publisher) for i in range(1001)), 1000) + + +class ProvideRequestSizeTest(unittest.TestCase): + """How many keys one /provide may carry: only RNS's request size limit bounds it, not the parser.""" + + def test_keys_per_request(self): + """Today 55 keys fit a 1 KiB request; the server takes them all in one /provide. rnfs's client sends 22.""" + keys = [ident(1000 + i) for i in range(1000)] + most = max(n for n in range(1, 100) + if protocol.request_size(PROVIDE_PATH, protocol.encode_provide(keys[:n], [])) <= MAX_REQUEST_SIZE) + self.assertEqual(most, 55) + # The parser itself has no count limit + self.assertEqual(protocol.parse_provide_request(protocol.encode_provide(keys, [])), (keys, [])) + # The request server lets RNS accept up to MAX_REQUEST_SIZE and records every key of such a request + backend = FakeBackend() + destination = FakeDestination() + server.RequestServer(destination, Config(), backend, clock=FakeClock(), start_monitor=False) + self.assertEqual(destination.max_request_size, MAX_REQUEST_SIZE) + link = FakeLink(b"l" * 16) + destination.links.append(link) + destination.established(link) + identity = RNS.Identity() + handler = destination.handlers[PROVIDE_PATH][0] + self.assertIs(handler(PROVIDE_PATH, protocol.encode_provide(keys[:most], []), bytes(16), link.link_id, + identity, 0.0), True) + self.assertEqual(backend.provided, [(node_id_for_identity(identity), keys[:most], [])]) + # rnfs's own client batches to one packet at the default link MDU + self.assertEqual(len(protocol.provide_batches(keys, [], None)[0][0]), 22) + + +if __name__ == "__main__": + unittest.main() From 077f33a37753aec469de49c72eb73c2e934960b2 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 01:49:16 +1030 Subject: [PATCH 006/107] Fuzz the strict parsers with hypothesis when it is installed --- tests/security/test_fuzz_parsers.py | 658 ++++++++++++++++++++++++++++ 1 file changed, 658 insertions(+) create mode 100644 tests/security/test_fuzz_parsers.py diff --git a/tests/security/test_fuzz_parsers.py b/tests/security/test_fuzz_parsers.py new file mode 100644 index 0000000..f9f3e6a --- /dev/null +++ b/tests/security/test_fuzz_parsers.py @@ -0,0 +1,658 @@ +"""Property-based fuzzing of the strict parsers that read untrusted bytes (security tooling, phase 2). + +Every parser here reads data from other nodes, archives, sealed files or LXMF messages. For each, arbitrary +input (raw bytes, arbitrary msgpack values, and structured near-valid values built from the real format) must +either parse or fail with the parser's documented outcome (None, or its own error type), never another +exception. Where an encoder exists, decode(encode(x)) == x, and for canonical formats encode(decode(b)) == b +for every accepted b. + +Needs hypothesis, which is a development tool and not one of rnfs's dependencies: without it every test here +is skipped. The default run is small and deterministic (derandomized); set RNFS_FUZZ_EXAMPLES (e.g. 3000) +for a deeper, randomized run. +""" +import io +import os +import shutil +import tempfile +import time +import unittest + +import RNS +from RNS.vendor import umsgpack + +from rnfs.constants import ID_LENGTH, K, MAX_BLOCK_SIZE +from rnfs.content import archive, builder, dag, seal, walker +from rnfs.content.cid import DAG, RAW, Cid +from rnfs.content.dag import Child, DirectoryNode, Entry, FileNode, IndexChild, IndexNode +from rnfs.content.storage import Storage +from rnfs.errors import InvalidDag, StorageFull +from rnfs.features import keepers, lxmf +from rnfs.features.lxmf import Incoming, Link +from rnfs.gateway import parse_range +from rnfs.names import encode_record, name_for_identity, parse_name, parse_record +from rnfs.network import protocol +from rnfs.network.announces import MODE_CLIENT, MODE_SERVER, parse_mode +from tests import test_lxmf + +try: + from hypothesis import HealthCheck, example, given, settings + from hypothesis import strategies as st +except ImportError: # not a dependency of rnfs: the module still loads, and every test in it is skipped + HAVE_HYPOTHESIS = False + + # Stands in for hypothesis's API (decorators, strategies and their combinations) when it is missing. No + # docstring: the skipped tests are instances of it, and unittest would print it as each one's description + class _Absent: + def __getattr__(self, name): + return self + + def __call__(self, *args, **kwargs): + return self + + def __or__(self, other): + return self + + st = _Absent() + example = given = settings = HealthCheck = _Absent() +else: + HAVE_HYPOTHESIS = True + +needs_hypothesis = unittest.skipUnless(HAVE_HYPOTHESIS, "hypothesis isn't installed (pip install hypothesis)") + +DEEP = os.environ.get("RNFS_FUZZ_EXAMPLES") +EXAMPLES = int(DEEP) if DEEP else 60 + + +def fuzz(scale: float = 1.0): + """Settings for one property: EXAMPLES × scale examples, no deadline, no example database written to the + working directory, and the same examples on every default run so the suite stays deterministic.""" + if not HAVE_HYPOTHESIS: + return settings() + return settings(max_examples=max(5, int(EXAMPLES * scale)), deadline=None, database=None, + derandomize=not DEEP, suppress_health_check=list(HealthCheck)) + + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence RNS's log (the LXMF feature and storage log what they reject).""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +# Strategies + +# What RNS's umsgpack can hand a parser: nil, bool, int, float, str, bin, arrays and maps of them +msgpack_leaves = (st.none() | st.booleans() | st.integers(-2**63, 2**64 - 1) | st.floats() + | st.binary(max_size=48) | st.text(max_size=24)) +msgpack_values = st.recursive( + msgpack_leaves, + lambda children: st.lists(children, max_size=6) + | st.dictionaries(st.text(max_size=8) | st.integers(0, 255), children, max_size=4), + max_leaves=16) +ids = st.binary(min_size=ID_LENGTH, max_size=ID_LENGTH) +cids = st.builds(Cid.for_block, st.sampled_from([RAW, DAG]), st.binary(max_size=8)) +# Binary fields that are almost right: valid CIDs, IDs, and bytes of CID-like lengths with CID-like prefixes +near_binaries = (cids.map(lambda cid: cid.binary) | ids | st.binary(max_size=48) + | st.tuples(cids, st.binary(max_size=2)).map(lambda pair: pair[0].binary[:-1] + pair[1])) +near_values = msgpack_leaves | near_binaries | st.lists(near_binaries, max_size=4) +uint64 = st.integers(0, 2**64 - 1) +valid_names = st.text(min_size=1, max_size=12).filter(dag.is_valid_name) + + +@st.composite +def mutated(draw, data: bytes) -> bytes: + """data with one byte changed, removed or inserted, or cut short.""" + kind = draw(st.sampled_from(["flip", "delete", "insert", "truncate"])) + if not data or kind == "insert": + index = draw(st.integers(0, len(data))) + return data[:index] + bytes([draw(st.integers(0, 255))]) + data[index:] + index = draw(st.integers(0, len(data) - 1)) + if kind == "flip": + return data[:index] + bytes([data[index] ^ draw(st.integers(1, 255))]) + data[index + 1:] + if kind == "delete": + return data[:index] + data[index + 1:] + return data[:index] + + +def unpacked(data: bytes): + """(True, value) if data is msgpack umsgpack decodes, else (False, None): wire bytes as RNS hands them on.""" + try: + return True, umsgpack.unpackb(data) + except Exception: # noqa: BLE001 - anything umsgpack rejects never reaches a parser + return False, None + + +# Network protocol (rnfs.network.protocol) + +cid_binaries = cids.map(lambda cid: cid.binary) +protocol_requests = ( + msgpack_values | near_values + | st.tuples(uint64, st.lists(cid_binaries, max_size=8)).map(list) + | st.tuples(st.lists(ids, max_size=12), st.lists(ids, max_size=12)).map(list) + | st.tuples(cid_binaries, st.booleans()).map(list) + | st.lists(near_values, min_size=2, max_size=2) + | st.tuples(uint64 | msgpack_leaves, st.lists(near_binaries, max_size=4)).map(list) + | st.tuples(st.lists(near_binaries, max_size=12), st.lists(near_binaries, max_size=12)).map(list) + | st.lists(st.none() | near_binaries | st.binary(max_size=MAX_BLOCK_SIZE // 4096), max_size=6)) + + +@needs_hypothesis +class ProtocolFuzzTest(unittest.TestCase): + """rnfs.network.protocol: every parse_* returns its documented result or None, never raising.""" + + def check_all(self, data): + """Run every request and response parser on data and check the shape of what they accept.""" + key = protocol.parse_find_request(data) + self.assertTrue(key is None or (type(key) is bytes and len(key) == ID_LENGTH)) + found = protocol.parse_find_response(data) + if found is not None: + self.assertTrue(all(len(ids_) <= K and all(protocol.is_id(i) for i in ids_) for ids_ in found)) + provided = protocol.parse_provide_request(data) + if provided is not None: + self.assertTrue(all(protocol.is_id(i) for keys in provided for i in keys)) + blocks = protocol.parse_blocks_request(data) + if blocks is not None: + self.assertTrue(type(blocks[0]) is int and blocks[0] >= 0) + self.assertTrue(all(isinstance(cid, Cid) for cid in blocks[1])) + for requested in (0, 1, 3, 8): + entries = protocol.parse_blocks_response(data, requested) + if entries is not None: + self.assertLessEqual(len(entries), requested) + self.assertTrue(all(e is None or (type(e) is bytes and len(e) <= MAX_BLOCK_SIZE) for e in entries)) + pin = protocol.parse_pin_request(data) + if pin is not None: + self.assertIsInstance(pin[0], Cid) + self.assertIs(type(pin[1]), bool) + self.assertIs(type(protocol.is_accepted(data)), bool) + cid = protocol.parse_cid(data) + self.assertTrue(cid is None or cid.binary == data) + + @fuzz() + @given(protocol_requests) + def test_any_value(self, data): + self.check_all(data) + + @fuzz() + @given(st.binary(max_size=96) | protocol_requests.map(umsgpack.packb).flatmap(mutated)) + def test_wire_bytes(self, data): + ok, value = unpacked(data) + if ok: + self.check_all(value) + + @fuzz(0.5) + @given(ids, st.lists(ids, max_size=K), st.lists(ids, max_size=K), uint64, st.lists(cids, max_size=8), + st.lists(st.none() | st.binary(max_size=64), max_size=8), cids, st.booleans()) + def test_round_trips(self, key, providers, nodes, max_bytes, wanted, entries, cid, pin): + def wire(value): + """value as it arrives: packed by the sender, unpacked by RNS.""" + return umsgpack.unpackb(umsgpack.packb(value)) + + self.assertEqual(protocol.parse_find_request(wire(protocol.encode_find(key))), key) + self.assertEqual(protocol.parse_find_response(wire(protocol.encode_find_response(providers, nodes))), + (providers, nodes)) + self.assertEqual(protocol.parse_provide_request(wire(protocol.encode_provide(providers, nodes))), + (providers, nodes)) + self.assertEqual(protocol.parse_blocks_request(wire(protocol.encode_blocks(max_bytes, wanted))), + (max_bytes, wanted)) + self.assertEqual(protocol.parse_pin_request(wire(protocol.encode_pin(cid, pin))), (cid, pin)) + # As many entries as CIDs requested is always well-formed + self.assertEqual(protocol.parse_blocks_response(wire(protocol.encode_blocks_response(entries)), len(entries)), + entries) + + +# DAG nodes (rnfs.content.dag) + +@st.composite +def dag_nodes(draw): + """A valid DAG node of any type, with sizes that can't overflow 2^64 - 1.""" + node_type = draw(st.sampled_from([dag.FILE, dag.DIRECTORY, dag.INDEX])) + sizes = st.integers(0, 2**60) + if node_type == dag.FILE: + return FileNode([Child(cid, size) for cid, size in draw(st.lists(st.tuples(cids, sizes), max_size=8))]) + names = sorted(draw(st.sets(valid_names, max_size=8))) + if node_type == dag.DIRECTORY: + kinds = st.sampled_from(dag.KINDS) + return DirectoryNode([Entry(name, draw(cids), draw(sizes), draw(kinds)) for name in names]) + return IndexNode([IndexChild(name, draw(cids), draw(sizes)) for name in names]) + + +# Near-valid node structures: right shape, any field values +dag_fields = near_values | valid_names | uint64 +dag_structures = st.tuples( + st.integers(0, 4) | msgpack_leaves, + st.lists(st.lists(dag_fields, min_size=2, max_size=4), max_size=4) | msgpack_values).map(list) + + +@needs_hypothesis +class DagFuzzTest(unittest.TestCase): + """dag.decode: a node or InvalidDag; accepted blocks are exactly what encode writes.""" + + def check(self, block: bytes): + """decode(block) raises only InvalidDag, and anything it accepts re-encodes to block.""" + try: + node = dag.decode(block) + except InvalidDag: + return + self.assertEqual(dag.encode(node), block) + + @fuzz() + @given(st.binary(max_size=128)) + def test_any_bytes(self, block): + self.check(block) + + @fuzz() + @given(dag_structures.map(umsgpack.packb) | dag_nodes().map(dag.encode).flatmap(mutated)) + def test_structured_and_mutated(self, block): + self.check(block) + + @fuzz() + @given(dag_nodes()) + def test_round_trip(self, node): + self.assertEqual(dag.decode(dag.encode(node)), node) + + @fuzz(0.5) + @given(dag_nodes()) + def test_non_shortest_headers_rejected(self, node): + block = dag.encode(node) + # The outer fixarray of 2 written as an array16: same value, not the encoding writers produce + self.assertEqual(block[0], 0x92) + with self.assertRaises(InvalidDag): + dag.decode(b"\xdc\x00\x02" + block[1:]) + + def test_huge_counts_fail_fast(self): + start = time.monotonic() + for block in (b"\x92\x01\xdd\xff\xff\xff\xff", b"\x92\x02\xdd\xff\xff\xff\xff" + b"\x94" * 1000, + b"\x92\x01\xdd\xff\xff\xff\xff" + b"\x92\xc4\x24" + bytes(36) + b"\x00" * 1000): + with self.assertRaises(InvalidDag): + dag.decode(block) + self.assertLess(time.monotonic() - start, 1.0) + + +# Name records (rnfs.names) + +@needs_hypothesis +class NameRecordFuzzTest(unittest.TestCase): + """parse_record: (sequence, CID) or None, with one encoding per record; parse_name: 16 bytes or ValueError.""" + + def check(self, app_data): + """parse_record never raises, and anything it accepts is the canonical encoding.""" + record = parse_record(app_data) + if record is not None: + sequence, cid = record + self.assertTrue(type(sequence) is int and 0 <= sequence < 2**64) + self.assertEqual(encode_record(sequence, cid), bytes(app_data)) + + @fuzz() + @given(st.binary(max_size=80) | msgpack_values) + def test_any_value(self, app_data): + self.check(app_data) + + @fuzz() + @given(st.tuples(uint64 | msgpack_leaves, near_binaries).map(list).map(umsgpack.packb) + | st.builds(encode_record, uint64, cids).flatmap(mutated)) + def test_structured_and_mutated(self, app_data): + self.check(app_data) + + @fuzz() + @given(uint64, cids) + def test_round_trip(self, sequence, cid): + self.assertEqual(parse_record(encode_record(sequence, cid)), (sequence, cid)) + + @fuzz() + @given(st.text(max_size=40) | ids.map(bytes.hex) + | st.tuples(st.sampled_from(["", " ", "<", "\t<"]), ids.map(bytes.hex), + st.sampled_from(["", " ", ">", ">\n"])).map("".join)) + def test_parse_name(self, text): + # Whitespace inside the hex digits is T1 (tests/security/test_t1_parse_name_whitespace.py); this checks + # the error type for every input and the length for the rest + try: + name = parse_name(text) + except ValueError: + return + if not any(c.isspace() for c in text.strip()): + self.assertEqual(len(name), 16) + + +# Keepers' signed records (rnfs.features.keepers) + +@needs_hypothesis +class KeepersFuzzTest(unittest.TestCase): + """parse_signed and the /name messages: a verified SignedRecord or None, never raising.""" + + @classmethod + def setUpClass(cls): + """Two name keys (making identities is the slow part).""" + cls.identities = [RNS.Identity(), RNS.Identity()] + cls.names = [name_for_identity(identity) for identity in cls.identities] + + def check(self, name, fields): + """Every entry point for untrusted records returns a SignedRecord for name, or None.""" + for record in (keepers.parse_signed(name, fields), keepers.parse_response(name, fields)): + self.assertTrue(record is None or (isinstance(record, keepers.SignedRecord) and record.name == name)) + if type(fields) is bytes: + for has_ratchet in (False, True): + record = keepers.from_announce_data(name, fields, has_ratchet) + self.assertTrue(record is None or record.name == name) + request = keepers.parse_request(fields) + self.assertTrue(request is None or len(request) == ID_LENGTH) + + @fuzz() + @given(st.sampled_from([0, 1]), msgpack_values | st.lists(st.binary(max_size=80), min_size=6, max_size=6) + | st.binary(max_size=300)) + def test_any_value(self, which, fields): + self.check(self.names[which], fields) + self.check(bytes(16), fields) + + @fuzz(0.5) + @given(st.sampled_from([0, 1]), uint64, cids, st.integers(0, 2**40 - 1), st.data()) + def test_round_trip_and_tampering(self, which, sequence, cid, now, data): + identity, name = self.identities[which], self.names[which] + record = keepers.sign_record(identity, name, encode_record(sequence, cid), now) + wire = umsgpack.unpackb(umsgpack.packb(keepers.encode_response(record))) + self.assertEqual(keepers.parse_response(name, wire), record) + self.assertEqual(keepers.parse_signed(name, record.wire()), record) + self.assertIsNone(keepers.parse_signed(self.names[1 - which], record.wire())) + announce = record.public_key + keepers.NAME_HASH + record.random_hash + record.signature + record.app_data + self.assertEqual(keepers.from_announce_data(name, announce, False), record) + # Any change to any field makes it a different record, which must not verify + fields = record.wire() + index = data.draw(st.integers(0, len(fields) - 1)) + fields[index] = data.draw(mutated(fields[index])) + self.assertIsNone(keepers.parse_signed(name, fields)) + self.assertIsNone(keepers.from_announce_data(name, data.draw(mutated(announce)), False)) + + +# Archives (rnfs.content.archive) + +def archive_header(roots: list) -> bytes: + """An archive's header listing roots (binary CIDs).""" + return umsgpack.packb(["rnfs-archive", 1, roots]) + + +def raw_archive(blocks: list[bytes]) -> bytes: + """An archive of raw blocks, the first (if any) as its root.""" + binaries = [Cid.for_block(RAW, block).binary for block in blocks] + return archive_header(binaries[:1]) + b"".join(umsgpack.packb(list(pair)) for pair in zip(binaries, blocks)) + + +archive_streams = ( + st.binary(max_size=200) + | st.lists(st.binary(max_size=64), max_size=4).map(raw_archive) + | st.lists(st.binary(max_size=64), max_size=4).map(raw_archive).flatmap(mutated) + | st.tuples(st.lists(near_binaries, max_size=3), + st.lists(st.tuples(near_binaries, st.binary(max_size=64)).map(list) | msgpack_values, max_size=4)) + .map(lambda pair: archive_header(pair[0]) + b"".join(umsgpack.packb(record) for record in pair[1]))) + + +@needs_hypothesis +class ArchiveFuzzTest(unittest.TestCase): + """read_archive and import_archive: ValueError (or StorageFull) for anything malformed, never another error; + a root is pinned only when every block of it came verified from the archive.""" + + def setUp(self): + """A storage to import into (shared by a test's examples: imports must hold whatever is already stored).""" + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.storage = Storage(os.path.join(self.tmp, "blocks"), os.path.join(self.tmp, "state"), 10**9) + + def read(self, data: bytes) -> list | None: + """Every record of the archive in data, or None if it is rejected (ValueError).""" + try: + roots, records = archive.read_archive(io.BytesIO(data)) + return [roots, list(records)] + except ValueError: + return None + + def check_import(self, data: bytes): + """Import data; complete roots must have every DAG block in the archive, with a matching CID.""" + parsed = self.read(data) + try: + result = archive.import_archive(io.BytesIO(data), self.storage) + except (ValueError, StorageFull): + return + verified = {cid for cid, block in (parsed[1] if parsed else []) if cid.verify(block)} + for root in result["complete"]: + self.assertLessEqual(self.storage.dag_blocks(root), verified) + self.assertTrue(self.storage.is_pinned(root)) + + @fuzz() + @given(archive_streams) + def test_read_any_stream(self, data): + self.read(data) + + @fuzz(0.5) + @given(archive_streams) + def test_import_any_stream(self, data): + self.check_import(data) + + @fuzz(0.25) + @given(st.lists(st.tuples(valid_names, st.binary(max_size=3000)), min_size=1, max_size=3, + unique_by=lambda entry: entry[0]), st.data()) + def test_round_trip_and_mutations(self, files, data): + source = Storage(os.path.join(self.tmp, "source"), os.path.join(self.tmp, "source-state"), 10**9) + entries = sorted((Entry(name, builder.build_bytes(content, source.put).cid, len(content), 0) + for name, content in files), key=lambda entry: entry.name) + root = builder.build_directory(entries, source.put).cid + out = io.BytesIO() + archive.export_archive([root], source.getter(), out) + exported = out.getvalue() + roots, records = self.read(exported) + self.assertEqual(roots, [root]) + self.assertEqual({cid for cid, _ in records}, set(source.dag_blocks(root))) + self.check_import(data.draw(mutated(exported))) + self.assertEqual(archive.import_archive(io.BytesIO(exported), self.storage)["complete"], [root]) + + +# LXMF fields, URIs and messages (rnfs.features.lxmf) + +links = st.builds( + Link, st.none() | cids, st.none() | ids, st.lists(valid_names, max_size=3).map(tuple), + st.none() | uint64, st.none() | ids).filter(lambda link: link.cid is not None or link.name is not None) +link_maps = st.dictionaries(st.sampled_from(["cid", "name", "path", "size", "from", "x"]) | st.text(max_size=4), + near_values | uint64 | st.lists(valid_names | msgpack_leaves, max_size=3), max_size=6) +lxmf_fields = (msgpack_values + | st.lists(link_maps, max_size=9).map(lambda data: {lxmf.FIELD_CUSTOM_TYPE: lxmf.CUSTOM_TYPE, + lxmf.FIELD_CUSTOM_DATA: data}) + | st.lists(links, min_size=1, max_size=8).map(lxmf.encode_field)) +uri_segments = st.text(alphabet="abcAZ09-._~%2Fe/?#&=: é", max_size=10) +uris = (st.text(max_size=60) + | st.tuples(st.sampled_from(["rnfs:", "RNFS:", "rnfs:name/", ""]), cids.map(str) | ids.map(bytes.hex), + st.lists(uri_segments, max_size=4).map("/".join), st.sampled_from(["", "/", "?", "?from=", "#"]), + st.sampled_from(["", "0" * 32, "x&from=" + "1" * 32])) + .map(lambda parts: parts[0] + parts[1] + ("/" + parts[2] if parts[2] else "") + parts[3] + parts[4]) + | links.map(lxmf.format_uri)) + + +@needs_hypothesis +class LxmfFuzzTest(test_lxmf.FeatureTestCase): + """decode_field, parse_uri, find_links and received(): links or ValueError (decode_field and parse_uri), + and received() never raising whatever a validated message carries.""" + + @fuzz() + @given(lxmf_fields) + def test_decode_field(self, fields): + for value in (fields, umsgpack.unpackb(umsgpack.packb(fields))): + try: + decoded = lxmf.decode_field(value) + except ValueError: + continue + if decoded is not None: + self.assertTrue(1 <= len(decoded) <= lxmf.MAX_LINKS and all(isinstance(li, Link) for li in decoded)) + + @fuzz() + @given(st.lists(links, min_size=1, max_size=lxmf.MAX_LINKS)) + def test_field_round_trip(self, sent): + fields = umsgpack.unpackb(umsgpack.packb(lxmf.encode_field(sent))) + self.assertEqual(lxmf.decode_field(fields), sent) + + @fuzz() + @given(uris) + def test_parse_uri(self, text): + for parse in (lxmf.parse_uri, lxmf.parse_target): + try: + link = parse(text) + except ValueError: + continue + # Whatever is accepted has a canonical form that reads back as the same link + self.assertEqual(lxmf.parse_uri(lxmf.format_uri(link)), link) + + @fuzz() + @given(links) + def test_uri_round_trip(self, link): + bare = Link(link.cid if link.name is None else None, link.name, link.path, None, link.from_node) + self.assertEqual(lxmf.parse_uri(lxmf.format_uri(bare)), bare) + + @fuzz() + @given(st.lists(uris | st.text(max_size=20), max_size=12).map(" \n".join) | st.text(max_size=200)) + def test_find_links(self, text): + found = lxmf.find_links(text) + self.assertLessEqual(len(found), lxmf.MAX_LINKS) + self.assertEqual(len(found), len(set(found))) + + @fuzz(0.5) + @given(st.binary(max_size=32) | st.text(max_size=8) | st.none(), + st.binary(max_size=400) | st.lists(uris, max_size=3).map(lambda u: "\n".join(u).encode()) | msgpack_leaves, + lxmf_fields, msgpack_leaves, msgpack_leaves) + def test_received(self, title, content, fields, timestamp, display_name): + if not hasattr(self, "feature"): + self.feature_node() + self.count = 0 + self.count += 1 + incoming = Incoming(test_lxmf.SENDER, self.count.to_bytes(32, "big"), timestamp, title, content, fields, + "direct", display_name) + entry_id = self.feature.received(incoming) + self.assertTrue(entry_id is None or type(entry_id) is int) + + +# Sealed files (rnfs.content.seal) + +def raw_reader(data: bytes): + """A RawReader over data held in memory.""" + def raw(offset: int, length: int): + yield data[offset:offset + length] + return raw + + +@needs_hypothesis +class SealFuzzTest(unittest.TestCase): + """Sealed-file headers and bodies: SealError (or NotRecipient) for anything malformed or tampered with.""" + + @classmethod + def setUpClass(cls): + """A recipient and a stranger.""" + cls.alice, cls.mallory = RNS.Identity(), RNS.Identity() + + @fuzz() + @given(st.binary(max_size=120) + | st.tuples(st.integers(0, 2**17) | msgpack_leaves, + st.lists(st.lists(st.binary(min_size=16, max_size=16) | st.binary(min_size=128, max_size=128) + | msgpack_leaves, min_size=2, max_size=2), max_size=3) | msgpack_values, + st.none() | st.binary(max_size=64) | msgpack_leaves).map(list).map(umsgpack.packb)) + def test_header(self, header): + try: + recipients, name = seal._decode_header(header) + except seal.SealError: + return + self.assertTrue(recipients) + self.assertTrue(all(len(h) == 16 and len(k) == seal.WRAPPED_KEY_SIZE for h, k in recipients.items())) + self.assertTrue(name is None or type(name) is bytes) + + @fuzz() + @given(st.binary(max_size=300).map(lambda body: seal.MAGIC + body) + | st.tuples(st.integers(0, 2), st.integers(0, 400), st.binary(max_size=400)) + .map(lambda t: seal.MAGIC + bytes([t[0]]) + t[1].to_bytes(4, "big") + t[2])) + def test_open_any_file(self, data): + try: + sealed = seal.open_sealed(raw_reader(data), len(data)) + if sealed is not None: + sealed.unlock(self.alice) + b"".join(sealed.read()) + except (seal.SealError, seal.NotRecipient): + pass + + @fuzz(0.25) + @given(st.binary(max_size=200), st.none() | valid_names, st.data()) + def test_round_trip_and_tampering(self, plaintext, name, data): + sealed_bytes = seal.seal_bytes(plaintext, [self.alice.get_public_key()], name) + opened = seal.open_sealed(raw_reader(sealed_bytes), len(sealed_bytes)) + opened.unlock(self.alice) + self.assertEqual(b"".join(opened.read()), plaintext) + self.assertEqual(opened.name, name) + with self.assertRaises(seal.NotRecipient): + opened.unlock(self.mallory) + # Any change anywhere: no longer sealed (magic), or an error before the whole plaintext is read + tampered = data.draw(mutated(sealed_bytes)) + with self.assertRaises((seal.SealError, seal.NotRecipient, _NotSealed)): + opened = seal.open_sealed(raw_reader(tampered), len(tampered)) + if opened is None: + raise _NotSealed() + opened.unlock(self.alice) + b"".join(opened.read()) + + +class _NotSealed(Exception): + """open_sealed found no magic.""" + + +# Small parsers of untrusted text and announce data + +@needs_hypothesis +class SmallParsersFuzzTest(unittest.TestCase): + """Announce app_data, CIDs, targets and HTTP Range headers.""" + + @fuzz() + @given(st.binary(max_size=16) | msgpack_values.map(umsgpack.packb)) + def test_parse_mode(self, app_data): + mode = parse_mode(app_data) + self.assertIn(mode, (MODE_SERVER, MODE_CLIENT)) + if mode == MODE_SERVER: + stream = io.BytesIO(app_data) + value = umsgpack.unpack(stream) + self.assertTrue(type(value) is int and value == 1 and not stream.read()) + + @fuzz() + @given(st.binary(max_size=48) | near_binaries, st.text(max_size=70) | cids.map(str) + | cids.map(lambda cid: str(cid).upper())) + def test_cid(self, binary, text): + try: + self.assertEqual(Cid.from_bytes(binary).binary, binary) + except ValueError: + pass + try: + self.assertEqual(str(Cid.parse(text)), text) + except ValueError: + pass + + @fuzz() + @given(st.text(max_size=40) | st.tuples(cids.map(str), st.lists(uri_segments, max_size=3)).map( + lambda t: "/".join([t[0], *t[1]]))) + def test_walker_target(self, text): + try: + cid, names = walker.parse_target(text) + except ValueError: + return + self.assertTrue(all(dag.is_valid_name(name) for name in names)) + + @fuzz() + @given(st.none() | st.text(max_size=30) + | st.tuples(st.sampled_from(["bytes=", "bytes= ", "Bytes=", "bytes=-"]), + st.text(alphabet="0123456789-, ٣²", max_size=45)).map("".join), + st.integers(0, 2**63 - 1)) + @example("bytes=" + "9" * 5000, 10) + def test_range(self, header, size): + result = parse_range(header, size) + if isinstance(result, tuple): + start, end = result + self.assertTrue(0 <= start <= end < size) + else: + self.assertIn(result, (None, "unsatisfiable")) + + +if __name__ == "__main__": + unittest.main() From da079d215438929229276721845e9657fa53524e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 01:49:16 +1030 Subject: [PATCH 007/107] Test that parse_name rejects whitespace between the hex digits --- .../security/test_t1_parse_name_whitespace.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/security/test_t1_parse_name_whitespace.py diff --git a/tests/security/test_t1_parse_name_whitespace.py b/tests/security/test_t1_parse_name_whitespace.py new file mode 100644 index 0000000..c97384f --- /dev/null +++ b/tests/security/test_t1_parse_name_whitespace.py @@ -0,0 +1,39 @@ +"""T1: names.parse_name accepts whitespace between the hex digits, returning a name shorter than 16 bytes. + +parse_name checks that the text is 32 characters long, then decodes it with bytes.fromhex, which skips +whitespace between byte pairs, so "00 00 ... 00" (32 characters, 11 bytes) passes as a name. Found by +tests/security/test_fuzz_parsers.py. The test gives parse_name such text and asserts the safe behaviour: a +ValueError, as for any other text that isn't 32 hex characters. +""" +import unittest + +from rnfs.names import parse_name + + +class ParseNameWhitespaceTest(unittest.TestCase): + """parse_name must reject anything but 32 hex digits (with optional ).""" + + # T1: fails until parse_name checks the text is hex digits only (like contacts.parse_hex's regex) + @unittest.expectedFailure + def test_inner_whitespace_is_rejected(self): + texts = ["00 " * 10 + "00", "00112233445566 77 8899aabbccddee", "<00112233445566\t77\n8899aabbccddee>"] + self.assertTrue(all(len(text.strip("<>")) == 32 for text in texts)) + accepted = {text: self.parsed(text).hex() for text in texts if not isinstance(self.parsed(text), ValueError)} + self.assertEqual(accepted, {}, "parse_name accepted text that isn't 32 hex digits") + + def test_canonical_forms_still_parse(self): + name = bytes(range(16)) + for text in (name.hex(), name.hex().upper(), f"<{name.hex()}>", f" {name.hex()}\n"): + self.assertEqual(parse_name(text), name) + + @staticmethod + def parsed(text): + """What parse_name returns for text, for the failure message.""" + try: + return parse_name(text) + except ValueError as e: + return e + + +if __name__ == "__main__": + unittest.main() From 68dcb8ff8e38dc438c4aaf5ee990b35b26804f47 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:26:06 +1030 Subject: [PATCH 008/107] Send a fixed message for gateway errors from the node and drop the path from StorageFull --- CHANGELOG.md | 8 ++++++++ design.md | 5 +++-- rnfs/content/storage.py | 5 +++-- rnfs/gateway.py | 25 ++++++++++++++++--------- specification.md | 5 +++-- tests/security/test_i1_507_message.py | 23 ++++++++++++----------- 6 files changed, 45 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9f83a..660ac23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ [specification.md](specification.md) is the authority on behaviour. +## Unreleased + +Gateway privacy: +- Errors from the node other than not-found (`400`, `413`, `422`, `503`, `507`, as well as `500`) get a fixed + message for their status; the detail goes to the daemon's log. A `507` used to show the local blocks + directory, the free disk space and the reserve. The storage-full message no longer names the directory + anywhere (the CLI and API still show free space against the reserve). + ## 0.1.3 Joining the DHT: diff --git a/design.md b/design.md index 8369bd3..3f97389 100644 --- a/design.md +++ b/design.md @@ -248,8 +248,9 @@ belongs to another user or can't be made 0700 (not on Windows). A response to a `X-Rnfs-Challenge: <32 hex>` carries `X-Rnfs-Proof: cli.api_proof(token, port, challenge)` (HMAC-SHA256 of `rnfs api proof `); `ApiClient.verify_daemon` checks it with `GET /api/status` before first sending the token. Both HTTP servers (`TimedHandler`) time out slow requests, cap concurrent connections (API -32, gateway 64) and refuse HTTP/0.9 with a 400 carrying the usual headers. Gateway 500s say only "internal -error" (the detail goes to the log); malformed `Range` headers are ignored. +32, gateway 64) and refuse HTTP/0.9 with a 400 carrying the usual headers. Gateway errors raised by the node +(anything but `GatewayError` and `NotFound`: 400, 413, 422, 503, 507, 500) say only a fixed message per status +(`gateway.ERROR_MESSAGES`; the detail goes to the log); malformed `Range` headers are ignored. ## Content additions diff --git a/rnfs/content/storage.py b/rnfs/content/storage.py index 38a41c9..f096649 100644 --- a/rnfs/content/storage.py +++ b/rnfs/content/storage.py @@ -677,8 +677,9 @@ def check_disk(self, nbytes: int): return # can't tell; writing the blocks reports a real failure reserve = max(DISK_RESERVE_MIN, int(usage.total * DISK_RESERVE_FRACTION)) if nbytes > usage.free - reserve: - raise StorageFull(f"not enough free disk space: {size(nbytes)} more would leave less than " - f"{size(reserve)} free in {self.blocks.directory} ({size(usage.free)} free)") + # No path: the message can travel beyond the local user (job errors, companion pages relaying daemon errors) + raise StorageFull(f"not enough free disk space: {size(nbytes)} more would leave less than the " + f"{size(reserve)} reserve free ({size(usage.free)} free)") def evict(self, needed: int = 0) -> set[Cid]: """Enforce max_storage, leaving room for needed more bytes: delete unreached blocks, then cached diff --git a/rnfs/gateway.py b/rnfs/gateway.py index 162c312..acd965b 100644 --- a/rnfs/gateway.py +++ b/rnfs/gateway.py @@ -20,8 +20,9 @@ - Access-Control-Allow-Origin: * on GET (and HEAD, which mirrors GET) only when the Referer's origin is this gateway (http://127.0.0.1: or http://localhost:), so sandboxed pages, whose origin is opaque, can fetch() their own DAG. Never in private mode. Vary: Referer keeps browser caches honest. -- Errors other than 500 say what went wrong; a 500 says only that, and its detail (which may name local - paths) goes to the log, since a gateway page may be allowed to read the response (the CORS rule above). +- A GatewayError (the gateway's own refusals) or NotFound says what went wrong. Every other error (a 400, + 413, 422, 503 or 507 from the node, or a 500) gets only a fixed message for its status: a node error's text + can name local paths, disk space or limits. The detail goes to the log (500: as an error; others verbose). - Requests that aren't the user's own navigation (Sec-Fetch-Site present and not "none") may hold at most gateway_fetches network fetches or name resolutions at a time; over that, 503 with Retry-After. Content already stored (node.is_local) is served without limit. A request with no Sec-Fetch-Site is @@ -47,7 +48,8 @@ from rnfs import gateway_formats # --- content --- from rnfs.content.cid import Cid from rnfs.content.seal import is_sealed # --- content --- -from rnfs.log import LOG_ERROR, log +from rnfs.errors import NotFound +from rnfs.log import LOG_ERROR, LOG_VERBOSE, log CSP_SCRIPTS = ("sandbox allow-scripts allow-forms allow-popups; " "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; form-action 'self'") @@ -59,6 +61,10 @@ NAME_CACHE_TIME = 60.0 NAME_CACHE_SIZE = 1000 INDEX = "index.html" +# What an error from the node says on the gateway, by status: never its own text (see the module docstring) +ERROR_MESSAGES = {400: "invalid request", 413: "too large for this node's download limits", + 422: "invalid content: it breaks the rnfs format's rules", 503: "the node is busy; try again shortly", + 507: "not enough free disk space on this node", 500: "internal error; see the rnfs daemon's log"} RANGE_NUMBER = re.compile(r"[0-9]{1,18}") # ASCII digits only (str.isdigit() takes "¹²³"), and below 2**63 # Types some platforms' mimetypes tables lack or get wrong (on Windows, mimetypes also reads the registry, @@ -370,14 +376,15 @@ def dispatch(self): self.serve() except GatewayError as e: self.respond_error(e.status, str(e), e.headers) + except NotFound as e: + self.respond_error(404, str(e) or "not found", {}) except Exception as e: # noqa: BLE001 - every error becomes a response, never a dropped connection status = error_status(e) - if status == 500: - log(f"gateway {self.command} {loggable(self.path)} failed: {e!r}", LOG_ERROR) - message = "internal error; see the rnfs daemon's log" # str(e) may name local paths - else: - message = str(e) or type(e).__name__ - self.respond_error(status, message, {"Retry-After": RETRY_AFTER} if status == 503 else {}) + # str(e) may name local paths (a StorageFull's or an OSError's) or the node's limits: logged only + log(f"gateway {self.command} {loggable(self.path)} failed: {e!r}", + LOG_ERROR if status == 500 else LOG_VERBOSE) + self.respond_error(status, ERROR_MESSAGES.get(status, ERROR_MESSAGES[500]), + {"Retry-After": RETRY_AFTER} if status == 503 else {}) finally: if self.slot: self.limiter.release() diff --git a/specification.md b/specification.md index 1d4fb75..edad851 100644 --- a/specification.md +++ b/specification.md @@ -886,8 +886,9 @@ gateway can never reach the API. allow-scripts allow-forms allow-popups; default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; form-action 'self'`. Pages run in an opaque origin and can't load resources from other servers; navigation and WebRTC can still reveal the viewer. A node MAY disable scripts (`gateway_scripts`). -- A `500` response says only that an internal error happened; its details, which may name local paths, go to - the log, since a gateway page may be allowed to read the response (below). +- The gateway's own refusals (a bad path or CID, a method other than GET or HEAD, a `409` or `416`…) and a `404` + say what went wrong. Any other error from the node (`400`, `413`, `422`, `503`, `507` or `500`) gets only a + fixed message for its status; the details, which may name local paths, disk space or limits, go to the log. - `GET` (and `HEAD`) responses carry `Access-Control-Allow-Origin: *` only when the request's `Referer` has the gateway's own origin (`127.0.0.1:8789` or `localhost:8789`), so pages can `fetch()` files in their own DAG despite their opaque origin, while other web sites can't read rnfs content directly. In private mode the diff --git a/tests/security/test_i1_507_message.py b/tests/security/test_i1_507_message.py index af8d47a..0f8cdca 100644 --- a/tests/security/test_i1_507_message.py +++ b/tests/security/test_i1_507_message.py @@ -1,9 +1,10 @@ -"""I1: a gateway 507 (storage full) carries the StorageFull message as is, which names the local blocks directory +"""I1: a gateway 507 (storage full) carried the StorageFull message as is, which named the local blocks directory and the disk's free space and reserve. A real offline node holds a file root whose chunks claim 2**60 bytes each; the gateway's GET of it fails the disk check before anything is fetched, and the 507's body is read back. The disk usage is fixed by a mock so the sizes in -the message are known. The fixed behaviour sends a generic message for 507, as it already does for 500. +the message are known. The gateway now sends a generic message for 507, as it does for 500, and the StorageFull +message itself no longer names the directory; it keeps the figures, for the local user's CLI and API. """ import os import shutil @@ -18,6 +19,7 @@ from rnfs.content import storage as storage_module from rnfs.content.cid import DAG, RAW, Cid from rnfs.content.storage import USER +from rnfs.errors import StorageFull from rnfs.gateway import GatewayServer from rnfs.log import size from rnfs.node import RnfsNode @@ -71,20 +73,19 @@ def local_details(self, body: str) -> list[str]: details = [self.config.rnfs_dir, self.config.blocks_dir, size(FREE), size(self.reserve)] return [detail for detail in details if detail in body] - def test_507_names_the_blocks_directory_and_disk_space(self): - status, body = self.get_root() - self.assertEqual(status, 507, body) - self.assertIn(self.config.blocks_dir, body) - self.assertIn(f"{size(FREE)} free", body) - self.assertIn(size(self.reserve), body) - - # I1: fails until the gateway sends a generic message for 507, without the local path or disk figures - @unittest.expectedFailure def test_507_message_is_generic(self): status, body = self.get_root() self.assertEqual(status, 507, body) self.assertEqual(self.local_details(body), [], f"the 507 body reveals local details: {body!r}") + def test_storage_full_names_no_path_but_keeps_the_figures(self): + # What the CLI and API show the local user: free space against the reserve, but not where the blocks are + with self.assertRaises(StorageFull) as caught: + self.node.storage.check_disk(2 ** 60) + message = str(caught.exception) + self.assertNotIn(self.config.rnfs_dir, message) + self.assertIn(f"{size(FREE)} free", message) + self.assertIn(size(self.reserve), message) if __name__ == "__main__": unittest.main() From d77be6663f5b4bfd6dcbc3ac2933b86f2e7a9bb6 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:27:44 +1030 Subject: [PATCH 009/107] Remove the gateway's CORS rule, which never fired in browsers --- CHANGELOG.md | 4 ++ README.md | 3 +- design.md | 6 ++ rnfs/gateway.py | 26 ++----- rnfs/gateway_formats.py | 6 +- specification.md | 14 ++-- tests/security/test_gateway_cors.py | 101 ++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 tests/security/test_gateway_cors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 660ac23..345d691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Gateway privacy: message for their status; the detail goes to the daemon's log. A `507` used to show the local blocks directory, the free disk space and the reserve. The storage-full message no longer names the directory anywhere (the CLI and API still show free space against the reserve). +- The gateway sends no CORS headers at all. The rule that allowed pages to `fetch()` files in their own DAG + (`Access-Control-Allow-Origin` for a gateway `Referer`) never worked in browsers, since sandboxed pages + send no `Referer`, and it is dropped from the specification. Pages still load their DAG's files as + images, scripts, style sheets and frames. Per-CID origins may bring reading back later. ## 0.1.3 diff --git a/README.md b/README.md index b5212c5..1b69c6a 100644 --- a/README.md +++ b/README.md @@ -769,7 +769,8 @@ Nicks are 1–32 characters of `a-z 0-9 - _ .`, starting with a letter or digit. you can `rnfs import`. You can check both against the CID without trusting the gateway. A web page's request or a `HEAD` gets an archive only of content stored completely on your node (409 otherwise); opening the URL yourself fetches it first. -- Pages are sandboxed. Sealed files are never decrypted by the gateway. +- Pages are sandboxed: they can show files of their own content (images, scripts, style sheets), but no page + can read gateway responses with `fetch()`. Sealed files are never decrypted by the gateway. ## Example use cases diff --git a/design.md b/design.md index 3f97389..a5d9d35 100644 --- a/design.md +++ b/design.md @@ -252,6 +252,12 @@ sending the token. Both HTTP servers (`TimedHandler`) time out slow requests, ca (anything but `GatewayError` and `NotFound`: 400, 413, 422, 503, 507, 500) say only a fixed message per status (`gateway.ERROR_MESSAGES`; the detail goes to the log); malformed `Range` headers are ignored. +Neither server sends CORS headers. Gateway pages run sandboxed in an opaque origin, so they load their DAG's +files as subresources but can't read any gateway response with `fetch()`; an earlier rule allowing a gateway +`Referer` never fired, as browsers send no `Referer` from an opaque origin, and allowing `Origin: null` instead +would let any site's sandboxed frame read the gateway. A separate origin per root (a subdomain per CID) is the +possible future way to let pages read their own DAG. + ## Content additions - `content/chunker.py` — `chunks(stream, size)` yields chunks from the chunker chosen with the context diff --git a/rnfs/gateway.py b/rnfs/gateway.py index acd965b..fc044c5 100644 --- a/rnfs/gateway.py +++ b/rnfs/gateway.py @@ -17,9 +17,11 @@ - Every response, errors and redirects included, carries X-Content-Type-Options: nosniff and the spec's sandboxing Content-Security-Policy. With gateway_scripts off, allow-scripts and 'unsafe-eval' are dropped and script-src 'none' added, so no script runs even if a browser ignores the sandbox. -- Access-Control-Allow-Origin: * on GET (and HEAD, which mirrors GET) only when the Referer's origin is - this gateway (http://127.0.0.1: or http://localhost:), so sandboxed pages, whose origin is - opaque, can fetch() their own DAG. Never in private mode. Vary: Referer keeps browser caches honest. +- No response carries Access-Control-* headers. Gateway pages run in a sandboxed, opaque origin, so they can + load files of their DAG as subresources (img, script, link…) but can't read gateway responses with fetch() + or XMLHttpRequest, and neither can any other web page. Sending Access-Control-Allow-Origin for + `Origin: null` would let every site's sandboxed iframe or data: page read the gateway. Per-CID origins + (a subdomain per root) would be the way to let pages read their own DAG, and are possible future work. - A GatewayError (the gateway's own refusals) or NotFound says what went wrong. Every other error (a 400, 413, 422, 503 or 507 from the node, or a 500) gets only a fixed message for its status: a node error's text can name local paths, disk space or limits. The detail goes to the log (500: as an error; others verbose). @@ -293,26 +295,10 @@ def end_headers(self): """Add the headers every response carries, then end the headers.""" self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Content-Security-Policy", CSP_SCRIPTS if self.config.gateway_scripts else CSP_NO_SCRIPTS) - if self.command in ("GET", "HEAD"): - self.send_header("Vary", "Referer") - if self.cors_allowed(): - self.send_header("Access-Control-Allow-Origin", "*") + # Deliberately no Access-Control-* headers, for any Origin or Referer (module docstring) self.headers_sent = True super().end_headers() - def cors_allowed(self) -> bool: - """True if the Referer is a page on this gateway and the node isn't in private mode.""" - if self.config.private: - return False - # Errors raised while parsing the request (e.g. 431) are sent before self.headers exists - headers = getattr(self, "headers", None) - referer = headers.get("Referer") if headers is not None else None - if not referer: - return False - url = urlsplit(referer) - port = self.server.server_address[1] - return url.scheme == "http" and url.netloc.lower() in (f"127.0.0.1:{port}", f"localhost:{port}") - def send_text(self, status: int, message: str, headers: dict | None = None): """Send a plain-text response.""" data = (message + "\n").encode("utf-8") diff --git a/rnfs/gateway_formats.py b/rnfs/gateway_formats.py index 04d6026..9b9da44 100644 --- a/rnfs/gateway_formats.py +++ b/rnfs/gateway_formats.py @@ -6,9 +6,9 @@ The client can verify either without trusting the gateway: a raw block hashes to its CID, and an archive's blocks are checked by `rnfs import` (or any reader) from the root down. Every gateway header rule still -applies (the handler's end_headers adds nosniff, the CSP and the CORS rule); both are immutable, with an -ETag naming the target and the format (".raw", ".archive") so they never collide with the plain -response's "". Fetches count against gateway_fetches like any other, and archives keep +applies (the handler's end_headers adds nosniff and the CSP, and no CORS header); both are immutable, with +an ETag naming the target and the format (".raw", ".archive") so they never collide with the +plain response's "". Fetches count against gateway_fetches like any other, and archives keep max_download_size and max_output_entries. /name/ paths don't take ?format= (the result must be named by the CID it is checked against). diff --git a/specification.md b/specification.md index edad851..8c53bc0 100644 --- a/specification.md +++ b/specification.md @@ -877,8 +877,8 @@ gateway can never reach the API. `max_download_size` and `max_output_entries` (413 otherwise), and the fetch is cancelled if the client disconnects. A bare-CID `If-None-Match` revalidation is answered 304 before anything else. - Both carry `ETag: ".raw"` or `".archive"` and `Cache-Control: immutable` - (`If-None-Match` answers 304), and every other gateway rule (the CSP, nosniff, the CORS rule, and for - `?format=raw` `gateway_fetches`) applies. Unknown formats get 400. + (`If-None-Match` answers 304), and every other gateway rule (the CSP, nosniff, no CORS headers, and + for `?format=raw` `gateway_fetches`) applies. Unknown formats get 400. - **Directories.** - A directory path without a trailing slash redirects to the slash form. - It is served as its `index.html` if present, otherwise as an HTML-escaped listing. @@ -889,10 +889,12 @@ gateway can never reach the API. - The gateway's own refusals (a bad path or CID, a method other than GET or HEAD, a `409` or `416`…) and a `404` say what went wrong. Any other error from the node (`400`, `413`, `422`, `503`, `507` or `500`) gets only a fixed message for its status; the details, which may name local paths, disk space or limits, go to the log. -- `GET` (and `HEAD`) responses carry `Access-Control-Allow-Origin: *` only when the request's `Referer` has the - gateway's own origin (`127.0.0.1:8789` or `localhost:8789`), so pages can `fetch()` files in their own DAG - despite their opaque origin, while other web sites can't read rnfs content directly. In private mode the - header is never sent. +- No response carries `Access-Control-*` headers, whatever the request's `Origin` or `Referer`. Pages run in an + opaque origin, so they can load files of their own DAG as subresources (images, scripts, style sheets, + frames) but can't read gateway responses with `fetch()` or `XMLHttpRequest`, and neither can other web + sites. A gateway MUST NOT allow `Origin: null`: every site's sandboxed frames and `data:` pages send it. + A separate origin per root (e.g. a subdomain per CID) would let pages read their own DAG; that is possible + future work. - Content fetched for the gateway is cache and is never provided. Names it resolves go only into the capped table (section 9). - Requests that aren't the user's own navigation (`Sec-Fetch-Site` other than `none`) may start at most diff --git a/tests/security/test_gateway_cors.py b/tests/security/test_gateway_cors.py new file mode 100644 index 0000000..b4fd1d2 --- /dev/null +++ b/tests/security/test_gateway_cors.py @@ -0,0 +1,101 @@ +"""Gateway CORS: no gateway response carries an Access-Control-* header, whatever the request's Origin or Referer. + +The rule that sent Access-Control-Allow-Origin for a Referer on the gateway never fired in browsers (sandboxed +pages have an opaque origin and send no Referer), and its obvious repair, allowing `Origin: null`, would let any +site's sandboxed frame or data: page read gateway responses (I1, L6). The rule is gone; these tests send every +kind of request the gateway answers, with a gateway Referer, `Origin: null`, another site's Origin and a CORS +preflight, and check that no response, errors and refusals included, carries a CORS header. +""" +import socket +import unittest +from unittest import mock + +from tests.test_api import read_until_closed +from tests.test_gateway import GatewayTestCase + +# Request headers a page could send: none, a gateway page's Referer (127.0.0.1 and localhost), the Origin of a +# sandboxed frame or data: page, another site's Origin, and a preflight's +HEADER_SETS = { + "plain": {}, + "gateway referer": {"Referer": "http://127.0.0.1:{port}/rnfs/{root}/page.html", "Sec-Fetch-Site": "same-origin"}, + "localhost referer": {"Referer": "http://localhost:{port}/", "Sec-Fetch-Site": "same-site"}, + "origin null": {"Origin": "null", "Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "cors"}, + "origin null and gateway referer": {"Origin": "null", "Referer": "http://127.0.0.1:{port}/rnfs/{root}/"}, + "other site": {"Origin": "http://evil.example", "Referer": "http://evil.example/", "Sec-Fetch-Site": "cross-site"}, + "preflight": {"Origin": "null", "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "range"}, +} + + +class NoCorsTest(GatewayTestCase): + """Every gateway response, for every kind of request and every Origin or Referer, lacks CORS headers.""" + + def requests(self) -> dict: + """name → (method, path) covering every kind of response: content, redirects, 304, ranges, errors.""" + return { + "file": ("GET", f"/rnfs/{self.text}"), + "head": ("HEAD", f"/rnfs/{self.text}"), + "listing": ("GET", f"/rnfs/{self.root}/"), + "index": ("GET", f"/rnfs/{self.sub}/"), + "redirect": ("GET", f"/rnfs/{self.root}"), + "range": ("GET", f"/rnfs/{self.text}?range"), + "missing": ("GET", f"/rnfs/{self.root}/missing"), + "bad cid": ("GET", "/rnfs/nope"), + "unknown path": ("GET", "/elsewhere"), + "raw": ("GET", f"/rnfs/{self.text}?format=raw"), + "archive": ("GET", f"/rnfs/{self.root}?format=archive"), + "archive head": ("HEAD", f"/rnfs/{self.root}?format=archive"), + "archive not stored": ("HEAD", f"/rnfs/{self.node.put_file(b'not stored', remote=True)}?format=archive"), + "name": ("GET", f"/name/{'00' * 16}/"), + "options": ("OPTIONS", f"/rnfs/{self.text}"), + "post": ("POST", f"/rnfs/{self.text}"), + } + + def assert_no_cors(self, headers, context): + cors = [name for name in headers.keys() if name.lower().startswith("access-control")] + self.assertEqual(cors, [], context) + + @mock.patch("rnfs.gateway.log") # the fake node has no block() or export_stream(): those are 500s, logged + def test_no_cors_header_in_any_response(self, _log): + statuses = set() + for set_name, template in HEADER_SETS.items(): + headers = {name: value.format(port=self.port, root=self.root) for name, value in template.items()} + for name, (method, path) in self.requests().items(): + extra = dict(headers) + if name == "range": + path, extra["Range"] = path.removesuffix("?range"), "bytes=2-" + status, response, _ = self.get(path, extra, method=method) + statuses.add(status) + self.assert_no_cors(response, (set_name, name, status)) + # A revalidation and an unsatisfiable range too + for extra in ({"If-None-Match": f'"{self.text}"'}, {"Range": "bytes=999-"}): + status, response, _ = self.get(f"/rnfs/{self.text}", {**headers, **extra}) + statuses.add(status) + self.assert_no_cors(response, (set_name, extra, status)) + status, response, _ = self.get(f"/rnfs/{self.text}", headers, host="evil.example") + statuses.add(status) + self.assert_no_cors(response, (set_name, "bad host", status)) + # The requests above did reach every kind of response + self.assertTrue({200, 206, 301, 304, 400, 403, 404, 405, 409, 416} <= statuses, statuses) + + def test_no_cors_in_private_mode_either(self): + self.config.private = True + self.start() + status, response, _ = self.get(f"/rnfs/{self.text}", {"Origin": "null", + "Referer": f"http://127.0.0.1:{self.port}/"}) + self.assertEqual(status, 200) + self.assert_no_cors(response, "private") + + def test_no_cors_on_responses_sent_without_a_handler(self): + # A malformed request line (answered before the headers are parsed) and the over-capacity 503 + with socket.create_connection(("127.0.0.1", self.port), timeout=10) as sock: + sock.sendall(f"GARBAGE\r\nHost: 127.0.0.1:{self.port}\r\nOrigin: null\r\n\r\n".encode()) + data, _ = read_until_closed(sock) + self.assertTrue(data.startswith(b"HTTP/1.0 400 "), data[:100]) + self.assertNotIn(b"access-control", data.lower()) + busy = self.server.server.RequestHandlerClass.busy_response() + self.assertNotIn(b"access-control", busy.lower()) + + +if __name__ == "__main__": + unittest.main() From a6784430d8ccb7533cc70e815182ecce1da717b4 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:30:03 +1030 Subject: [PATCH 010/107] Answer web pages' gateway requests the same whether content is stored or not --- CHANGELOG.md | 6 + README.md | 4 +- design.md | 15 +- rnfs/gateway.py | 22 ++- rnfs/gateway_formats.py | 19 ++- specification.md | 21 ++- .../test_l6_gateway_storage_oracle.py | 159 ++++++++++++++++++ 7 files changed, 212 insertions(+), 34 deletions(-) create mode 100644 tests/security/test_l6_gateway_storage_oracle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 345d691..c7f60c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ Gateway privacy: (`Access-Control-Allow-Origin` for a gateway `Referer`) never worked in browsers, since sandboxed pages send no `Referer`, and it is dropped from the specification. Pages still load their DAG's files as images, scripts, style sheets and frames. Per-CID origins may bring reading back later. +- `?format=archive` answers every web page's request and every `HEAD` with `409`, stored or not. It used to + answer `200` for content stored on the node, which let any web site tell what the user had downloaded or + viewed (an iframe of a download fires no load event). The user's own `GET` is unchanged. +- A web page's `HEAD`, or range not starting at byte 0, takes its Content-Type from the file's first bytes, + fetched if need be, as its `GET` does. It used to sniff them only if stored (else + `application/octet-stream`), which a page may be able to observe (e.g. through opaque-response blocking). ## 0.1.3 diff --git a/README.md b/README.md index 1b69c6a..8335beb 100644 --- a/README.md +++ b/README.md @@ -767,8 +767,8 @@ Nicks are 1–32 characters of `a-z 0-9 - _ .`, starting with a letter or digit. - `?filename=` sets the type and the download name. - `/rnfs/?format=raw` returns the block, and `/rnfs/[/path]?format=archive` returns an archive you can `rnfs import`. You can check both against the CID without trusting the gateway. A web page's - request or a `HEAD` gets an archive only of content stored completely on your node (409 otherwise); - opening the URL yourself fetches it first. + request or a `HEAD` never gets an archive (409), so pages can't tell what your node stores; opening the + URL yourself fetches what isn't stored first. - Pages are sandboxed: they can show files of their own content (images, scripts, style sheets), but no page can read gateway responses with `fetch()`. Sealed files are never decrypted by the gateway. diff --git a/design.md b/design.md index a5d9d35..d93a4b5 100644 --- a/design.md +++ b/design.md @@ -195,10 +195,10 @@ out within `link_idle_timeout`. | `add_stream(stream, size=None, name=None, only_hash=False, …)` | as above; `name` wraps it as that entry (sealed: the sealed name) | | `get(target, output, from_node=None, raw=False)` | `{"cid", "warnings"}` (+ `"warnings_total"` past 100; + `"sealed", "name"`, the name only if `safe_default_name` accepts it); fetches and writes the file or directory under `.rnfs-get-.partial` beside `output`, then renames it to `output` (`rename_new`), decrypting a sealed file unless `raw` | | `info(target, for_gateway=False, from_node=None, raw=False)` | `{"cid", "type": "file"\|"directory", "size", "executable"}` (+ `"sealed", "recipient", "stored_size", "name"`; the sealed name is None unless `safe_default_name` accepts it) | -| `stored_head(target, length)` | a file's first `length` bytes if stored, else None; never fetches (gateway `HEAD` and ranges not at 0) | +| `stored_head(target, length)` | a file's first `length` bytes if stored, else None; never fetches (the user's own gateway `HEAD` and ranges not at 0) | | `read(target, offset=0, length=None, for_gateway=False, from_node=None, raw=False)` | iterator of bytes of a file (range fetched only; sealed files decrypted unless `raw` or `for_gateway`) | | `block(cid, for_gateway=False)` | the CID's own block, verified (`cat --block`, `?format=raw`) | -| `export_stream(target, start, for_gateway=False)` | fetches the DAG, then writes its archive to `start(exact length)` (`?format=archive`; `None` for HEAD). The gateway calls it for a limited request or a HEAD only when `is_local(target)`, and otherwise inside `fetcher.observing(ClientWatch)`, which cancels the fetch once the client disconnects | +| `export_stream(target, start, for_gateway=False)` | fetches the DAG, then writes its archive to `start(exact length)` (`?format=archive`; `None` for HEAD). The gateway calls it only for the user's own GET (never for a limited request or a HEAD, stored or not), inside `fetcher.observing(ClientWatch)`, which cancels the fetch once the client disconnects | | `ls(target, for_gateway=False)` | `[{"name", "kind": "file"\|"executable"\|"directory", "size", "cid"}]` | | `stat(target)` | `{"cid", "size", "blocks", "local_blocks", "local_bytes"}` | | `is_local(target)` | `bool`: everything needed is stored, so no network fetch | @@ -274,17 +274,18 @@ possible future way to let pages read their own DAG. identity hash; the node's own identity; contacts), `open_sealed` over a Fetch's `read_range`, `sealed_info`, `write_sealed`, `block`, `export_stream`, and the API option parser `add_options`. - `gateway_formats.py` — `?format=raw|archive`; `archive.archive_size` gives the exact length, so the archive - is sent with a Content-Length before streaming. An archive for a web page's request or a HEAD is made only - of a target stored completely (else 409), so archives never take a `gateway_fetches` slot; a bare-CID - `If-None-Match` revalidation answers 304 first. + is sent with a Content-Length before streaming. A web page's request or a HEAD always gets 409, stored or + not (a 200 for stored targets only told pages what is stored: L6), so archives never take a + `gateway_fetches` slot; a bare-CID `If-None-Match` revalidation answers 304 first. - `content/seal.py` rejects a final segment of more than 65536 plaintext bytes, and `SealedFile.name` is None for a name that fails its tag, isn't UTF-8 or isn't a valid name. `write_sealed` reads the name before creating the output, so nothing raised there leaves a file. `open_sealed` reports the stored (ciphertext) size as a job's total, which is what its done bytes count. - Cost: `get`, `cat` and `info` of a file read its first 12 bytes to detect sealing (`--raw` skips this), so the first chunk is fetched first even for a `cat --offset` deep in a file. The gateway does so only for a - GET from byte 0; a HEAD, or a range not at 0, uses the first bytes only if already stored - (`stored_head`), else takes the Content-Type from the name, or `application/octet-stream`. + GET from byte 0 or a web page's request (`limited()`, so its type never depends on what is stored); the + user's own HEAD, or range not at 0, uses the first bytes only if already stored (`stored_head`), else takes + the Content-Type from the name, or `application/octet-stream`. ## Mesh diff --git a/rnfs/gateway.py b/rnfs/gateway.py index fc044c5..863e36c 100644 --- a/rnfs/gateway.py +++ b/rnfs/gateway.py @@ -6,9 +6,11 @@ Behaviour: - Path segments are percent-decoded one by one; a decoded segment can't contain "/" or NUL, so every segment is exactly one name. -- Files: Content-Type from ?filename=, else the path's last name, else sniffed from the first bytes. A - HEAD, or a range not starting at byte 0, uses the first bytes only if they are stored (else the name, or - application/octet-stream), so it never fetches what it doesn't send. +- Files: Content-Type from ?filename=, else the path's last name, else sniffed from the first bytes. The + user's own HEAD, or range not starting at byte 0, uses the first bytes only if they are stored (else the + name, or application/octet-stream), so it never fetches what it doesn't send. A web page's request (see + limited() below) always reads them, fetching them if need be, so what a page can observe of the type + (e.g. through nosniff or opaque-response blocking) never depends on what is stored. Single byte ranges (206, 416, Accept-Ranges; If-Range honoured); several ranges get the whole file. - Directories: without a trailing slash, 301 to the slash form; with one, index.html if present, else an HTML-escaped listing with percent-encoded links. @@ -504,8 +506,9 @@ def serve_file(self, target: str, size: int, filename: str | None, cache: dict): byte_range = None # the client's partial copy may be of something else: send it all if byte_range == "unsatisfiable": raise GatewayError(416, "range not satisfiable", {"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"}) - content_type = self.file_type(target, size, filename, self.command != "HEAD" and - (byte_range is None or byte_range[0] == 0)) + # A web page's HEAD or later range reads the first bytes too: its type mustn't depend on what is stored + content_type = self.file_type(target, size, filename, self.limited() or ( + self.command != "HEAD" and (byte_range is None or byte_range[0] == 0))) headers = {"Accept-Ranges": "bytes", **cache} if self.param("filename"): headers["Content-Disposition"] = f"inline; filename*=UTF-8''{quote(self.param('filename'), safe='')}" @@ -521,14 +524,15 @@ def serve_file(self, target: str, size: int, filename: str | None, cache: dict): chunks = iter(self.node.read(target, offset=start, length=length, for_gateway=True)) self.send_stream(status, content_type, length, headers, chunks) - def file_type(self, target: str, size: int, filename: str | None, sends_start: bool) -> str: + def file_type(self, target: str, size: int, filename: str | None, read_start: bool) -> str: """A file's Content-Type: octet-stream for a sealed file (never decrypted here), else by name or sniffed. - The first bytes are fetched only when the response sends them anyway; for a HEAD or a range starting - later they are used only if already stored, so such a request fetches nothing it doesn't send. + With read_start (a response that sends the first bytes anyway, or any web page's request) they are + read, fetched if need be. Otherwise (the user's own HEAD or range starting later) they are used only if + already stored, so such a request fetches nothing it doesn't send. """ length = min(size, SNIFF_SIZE) - if sends_start: + if read_start: head = b"".join(self.node.read(target, offset=0, length=length, for_gateway=True)) else: stored_head = getattr(self.node, "stored_head", None) # facades without it: only complete targets diff --git a/rnfs/gateway_formats.py b/rnfs/gateway_formats.py index 9b9da44..3a67a4a 100644 --- a/rnfs/gateway_formats.py +++ b/rnfs/gateway_formats.py @@ -12,10 +12,12 @@ max_download_size and max_output_entries. /name/ paths don't take ?format= (the result must be named by the CID it is checked against). -An archive needs the target's whole DAG before its first byte (its length is in the headers), so a web -page's request (Sec-Fetch-Site present and not "none") or a HEAD gets one only of a target already stored -completely, else 409; nobody waiting for a body shouldn't make the node fetch a whole DAG. The user's own -GET of one not stored fetches it, and that fetch is cancelled once the client disconnects. +An archive needs the target's whole DAG before its first byte (its length is in the headers), so it is made +only for the user's own GET (handler.limited() false: no Sec-Fetch-Site, or "none"), which fetches what +isn't stored; that fetch is cancelled once the client disconnects. A web page's request or a HEAD always +gets 409, stored or not: nobody waiting for a body shouldn't make the node fetch a whole DAG, and answering +200 for stored targets only would tell web pages what the node stores (an iframe of a 200 with +Content-Disposition attachment becomes a download and fires no load event; a 409 page does). """ import select import socket @@ -56,10 +58,11 @@ def serve_format(handler, segments: list[str], names: list[str]): if not names and etag_matches(handler.headers.get("If-None-Match"), f'"{root}.archive"'): return _not_modified(handler, f'"{root}.archive"', IMMUTABLE) # a revalidation needs nothing stored - if (handler.limited() or handler.command == "HEAD") and not handler.node.is_local(target): - raise GatewayError(409, "an archive of content not completely stored on this node is only made for a GET " - "of the user's own (not a web page's request or a HEAD): fetching it takes the " - "whole DAG; open the URL directly or `rnfs pin` it first") + if handler.limited() or handler.command == "HEAD": + # Whether the target is stored or not: the answer must not tell a web page which it is + raise GatewayError(409, "an archive is only made for a GET of the user's own, never for a web page's " + "request or a HEAD, whether the content is stored or not: open the URL directly, " + "or use `rnfs export`") if names: cid = handler.node.info(target, for_gateway=True)["cid"] # the ETag names the target, not the root else: diff --git a/specification.md b/specification.md index 8c53bc0..68a2e41 100644 --- a/specification.md +++ b/specification.md @@ -862,9 +862,11 @@ gateway can never reach the API. the whole file sent. - `/rnfs/` responses carry `ETag: ""` and `Cache-Control: immutable`. - Sealed files are served as `application/octet-stream`, whatever their name says, and never decrypted; - the gateway reads a file's first bytes to recognise them. A `HEAD`, or a range not starting at byte 0, - uses those bytes (to recognise a sealed file, or to sniff) only if they are already stored, and never - fetches them: its Content-Type then comes from the name, or is `application/octet-stream`. + the gateway reads a file's first bytes to recognise them. The user's own `HEAD`, or range not starting + at byte 0, uses those bytes (to recognise a sealed file, or to sniff) only if they are already stored, + and never fetches them: its Content-Type then comes from the name, or is `application/octet-stream`. A + request that isn't the user's own navigation (below) always reads them, fetching them if need be, so its + Content-Type doesn't depend on what the node stores. - **Trustless responses** (`/rnfs/` only; `/name/` answers `?format=` with 400): - `/rnfs/?format=raw` returns the CID's own block, re-verified, as `application/vnd.rnfs.raw`; a path is refused (400). @@ -872,10 +874,10 @@ gateway can never reach the API. `application/vnd.rnfs.archive` with `Content-Disposition: attachment; filename=".rnfs-archive"` and an exact `Content-Length`; it is byte-identical to `rnfs export`. Its length must be known before its first byte, so it needs the whole DAG: a request that isn't the user's own navigation (`Sec-Fetch-Site` - present and not `none`) and any `HEAD` get an archive only of a target stored completely on the node, - and otherwise `409 Conflict`. The user's own `GET` fetches the whole DAG first, within - `max_download_size` and `max_output_entries` (413 otherwise), and the fetch is cancelled if the client - disconnects. A bare-CID `If-None-Match` revalidation is answered 304 before anything else. + present and not `none`) and any `HEAD` get `409 Conflict`, whether the target is stored or not (an answer + that depended on it would tell web pages what the node stores). The user's own `GET` fetches the whole + DAG first, within `max_download_size` and `max_output_entries` (413 otherwise), and the fetch is + cancelled if the client disconnects. A bare-CID `If-None-Match` revalidation is answered 304 before anything else. - Both carry `ETag: ".raw"` or `".archive"` and `Cache-Control: immutable` (`If-None-Match` answers 304), and every other gateway rule (the CSP, nosniff, no CORS headers, and for `?format=raw` `gateway_fetches`) applies. Unknown formats get 400. @@ -899,7 +901,10 @@ gateway can never reach the API. table (section 9). - Requests that aren't the user's own navigation (`Sec-Fetch-Site` other than `none`) may start at most `gateway_fetches` network fetches or name resolutions at a time. Content already stored is served without - limit. + limit. Browsers send `Sec-Fetch-Site` to `127.0.0.1` and `localhost`, and `none` only for a navigation the + user started (address bar, bookmark); a request without it comes from a local tool the user runs. +- Apart from timing, the `gateway_fetches` limit and content no provider has, a web page's request gets the + same answer whether the node stores the content or has to fetch it. - The gateway never expands contact nicks (`/name//` is not supported), so web pages can't use or probe the address book. diff --git a/tests/security/test_l6_gateway_storage_oracle.py b/tests/security/test_l6_gateway_storage_oracle.py new file mode 100644 index 0000000..493a514 --- /dev/null +++ b/tests/security/test_l6_gateway_storage_oracle.py @@ -0,0 +1,159 @@ +"""L6: the gateway told web pages which CIDs the node stores: `?format=archive` answered a web page's request or a HEAD +with 200 for a stored target and 409 for one not stored, and a HEAD's Content-Type was sniffed from stored first +bytes but application/octet-stream when they weren't. + +Two gateways serve the same content (the same CIDs) from two fake nodes, one storing it and one that has to fetch +every block from its "network" each time (its first bytes are never stored, as for a big file whose first chunk +isn't). Every request a web page can start (Sec-Fetch-Site other than "none") and every archive HEAD must get the +same status, headers and body from both. The user's own requests (no Sec-Fetch-Site, or "none") may differ: only +the user sees them. A last test documents what still differs while gateway_fetches is full. +""" +import json +import unittest + +from rnfs.config import Config +from rnfs.gateway import GatewayServer + +from tests.test_api import FakeNode, raw_request + +PAGE = b"

a page

\n" * 30 # sniffed as text/html, and longer than the sniffed prefix +PNG = b"\x89PNG\r\n\x1a\n" + bytes(600) + +# What a web page's requests carry: a cross-site page's subresource or frame, a gateway page's (opaque origin, +# so cross-site too), and the other values a browser may send +PAGE_REQUESTS = { + "cross-site image": {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Dest": "image"}, + "cross-site iframe": {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe"}, + "cross-site fetch": {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Dest": "empty", + "Origin": "null"}, + "same-site": {"Sec-Fetch-Site": "same-site", "Sec-Fetch-Mode": "no-cors"}, + "same-origin": {"Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors"}, +} + + +class NetworkNode(FakeNode): + """A FakeNode whose remote CIDs stay remote: every read of one is a network fetch (counted in `fetches`), and + nothing of them is ever stored. stored_head, block and export_stream answer as RnfsNode's do.""" + + def __init__(self): + super().__init__() + self.fetches = 0 + + def _fetch(self, cid: str): + if cid in self.remote: + with self.lock: + self.fetches += 1 + + def stored_head(self, target, length): + self.record("stored_head", target, length) + try: + path = self._path(target) + except Exception: # noqa: BLE001 - "invalid" is None too, as for RnfsNode + return None + obj = self.objects.get(path[-1]) + if obj is None or obj[0] != "file" or set(path) & self.remote: + return None + return obj[1][:length] + + def block(self, cid, for_gateway=False): + self.record("block", cid, for_gateway=for_gateway) + obj = self._object(cid) + return obj[1] if obj[0] == "file" else json.dumps(sorted(obj[1].items())).encode() + + def export_stream(self, target, start, for_gateway=False): + self.record("export_stream", target, for_gateway=for_gateway) + data = b"archive of " + self._resolve(target).encode() + out = start(len(data)) + if out is not None: + out.write(data) + return {} + + +class StorageOracleTest(unittest.TestCase): + """The same requests to a gateway whose node stores the content and to one whose node has to fetch it.""" + + def setUp(self): + self.gateways = {} + for kind in ("stored", "unstored"): + node = NetworkNode() + remote = kind == "unstored" + page = node.put_file(PAGE, remote=remote) + data = node.put_file(PNG, remote=remote) + site = node.put_dir({"index.html": page, "page.html": page, "data": data}, remote=remote) + server = GatewayServer(node, Config(gateway_fetches=2), 0) + server.start() + self.addCleanup(server.stop) + self.gateways[kind] = (node, server) + self.page, self.data, self.site = page, data, site # the same CIDs on both nodes + + def paths(self) -> list[str]: + """Every kind of /rnfs/ path: bare files, files by name and sniffed, a site, and both formats.""" + return [f"/rnfs/{self.page}", f"/rnfs/{self.data}", f"/rnfs/{self.site}/", f"/rnfs/{self.site}/page.html", + f"/rnfs/{self.site}/data", f"/rnfs/{self.site}", f"/rnfs/{self.page}?filename=x.bin", + f"/rnfs/{self.page}?format=raw", f"/rnfs/{self.site}?format=archive", + f"/rnfs/{self.site}/page.html?format=archive", f"/rnfs/{self.page}?format=archive"] + + def both(self, method: str, path: str, headers: dict) -> dict: + """kind → (status, headers without Date, body) of the request to each gateway.""" + responses = {} + for kind, (_, server) in self.gateways.items(): + status, response, body = raw_request(server.port, method, path, None, headers) + responses[kind] = (status, sorted((k, v) for k, v in response.items() if k != "Date"), body) + return responses + + def assert_same(self, method: str, path: str, headers: dict, context): + responses = self.both(method, path, headers) + self.assertEqual(responses["stored"], responses["unstored"], (context, method, path)) + return responses["stored"] + + def test_page_requests_dont_depend_on_storage(self): + for name, headers in PAGE_REQUESTS.items(): + for path in self.paths(): + for method in ("GET", "HEAD"): + self.assert_same(method, path, headers, name) + # A media element's seek: a range not starting at byte 0, so its first bytes aren't sent + for method in ("GET", "HEAD"): + status = self.assert_same(method, f"/rnfs/{self.page}", {**headers, "Range": "bytes=600-"}, name)[0] + self.assertEqual(status, 206) + self.assertGreater(self.gateways["unstored"][0].fetches, 0) # the unstored node really did fetch + + def test_a_pages_head_has_the_sniffed_type(self): + # Sniffed from the first bytes (fetched if need be), as its GET is: not octet-stream for content not stored + for path, expected in ((f"/rnfs/{self.page}", "text/html"), (f"/rnfs/{self.data}", "image/png"), + (f"/rnfs/{self.site}/data", "image/png")): + status, headers, body = self.assert_same("HEAD", path, PAGE_REQUESTS["cross-site image"], path) + self.assertEqual((status, dict(headers)["Content-Type"], body), (200, expected, b""), path) + + def test_archives_for_pages_and_heads_are_always_409(self): + users = {"no Sec-Fetch-Site": {}, "Sec-Fetch-Site none": {"Sec-Fetch-Site": "none"}} + for path in (f"/rnfs/{self.site}?format=archive", f"/rnfs/{self.site}/page.html?format=archive"): + for name, headers in PAGE_REQUESTS.items(): + for method in ("GET", "HEAD"): + self.assertEqual(self.assert_same(method, path, headers, name)[0], 409, (name, method)) + for name, headers in users.items(): # the user's own HEAD + self.assertEqual(self.assert_same("HEAD", path, headers, name)[0], 409, name) + for (node, _) in self.gateways.values(): + self.assertEqual(node.called("export_stream"), []) + + def test_users_own_archive_get_still_works(self): + # The user's own navigation (Sec-Fetch-Site: none) or tool (no header) gets the archive, fetching it if needed + fetches = self.gateways["unstored"][0].fetches + for headers in ({}, {"Sec-Fetch-Site": "none"}): + status, _, body = self.assert_same("GET", f"/rnfs/{self.site}?format=archive", headers, headers) + self.assertEqual((status, body), (200, f"archive of {self.site}".encode())) + self.assertGreater(self.gateways["unstored"][0].fetches, fetches) + + # L6 (residual): fails until a web page's request for stored content is answered as one for content not stored + # while every gateway_fetches slot is busy (today stored content is served without a slot: 200 against 503) + @unittest.expectedFailure + def test_full_fetch_slots_dont_reveal_storage(self): + for node, server in self.gateways.values(): + limiter = server.server.RequestHandlerClass.limiter + for _ in range(limiter.limit): # a page holds every slot with fetches that never finish + self.assertTrue(limiter.try_acquire()) + self.addCleanup(lambda limiter=limiter: [limiter.release() for _ in range(limiter.limit)]) + self.assert_same("GET", f"/rnfs/{self.data}", PAGE_REQUESTS["cross-site image"], "slots full") + + +if __name__ == "__main__": + unittest.main() From 211c4703d2ca866990b8efe19581ff24e670beb9 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:31:33 +1030 Subject: [PATCH 011/107] Bound the bytes of a fetched DAG by what its declared size and directory entries can hold --- CHANGELOG.md | 11 + design.md | 24 ++ rnfs/features/follow.py | 4 +- rnfs/fetcher.py | 106 ++++++- rnfs/node.py | 5 +- rnfs/node_content.py | 3 +- specification.md | 25 +- tests/security/test_dag_caps.py | 280 +++++++++++++++++++ tests/security/test_l3_zero_size_children.py | 35 ++- tests/security/test_l5_follow_cap.py | 96 +++++++ tests/security/test_n1_user_pin_unbounded.py | 32 +-- 11 files changed, 562 insertions(+), 59 deletions(-) create mode 100644 tests/security/test_dag_caps.py create mode 100644 tests/security/test_l5_follow_cap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9f83a..2dea99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ [specification.md](specification.md) is the authority on behaviour. +## Unreleased + +Fixes: +- A fetch of a whole DAG (`get`, `pin add`, `export`, `dir set`/`dir rm`, a followed name's new target, gateway + archives, remote pins and LXMF auto-fetches) is refused with "too large" once the DAG's blocks hold more than + its declared size can: the size plus 64 bytes per 4 KiB, 1 KiB, and 1 KiB per directory entry met. A file + node may list empty children, so a file declaring 1 byte could make `get` or `pin add` download and keep any + amount of data. Content built by `add` (either chunker) stays far inside the bound, and fetches of it ask + for blocks as before. Requests under the bound never ask for more than it leaves, so a refused fetch + downloads at most about one block too many. + ## 0.1.3 Joining the DHT: diff --git a/design.md b/design.md index 8369bd3..85b80b7 100644 --- a/design.md +++ b/design.md @@ -436,6 +436,30 @@ Everything in the specification is implemented, with these simplifications and a - `max_download_size` is checked against the target's verified size, or the range's size, before anything below the target is fetched, and the DAG-node bytes a fetch downloads count against it too, so directories of empty files can't get around it. Listing and writing out also stop fetching at `max_output_entries`. +- **DAG byte bound** (`fetcher.dag_bound`, `Fetch.dag_limit`). `fetch_dag` counts the bytes of the distinct + blocks of its target's DAG, stored or downloaded, against `dag_bound(size)` = size + `DAG_GRANULE_OVERHEAD` + (64) per `DAG_GRANULE` (4 KiB, `SmallLeaves`' threshold) of the declared size + `DAG_SLACK` (1 KiB), plus + `DAG_ENTRY_OVERHEAD` (1 KiB) per directory entry or index child of the counted nodes (at most + `max_output_entries` of them), and `max_dag_bytes` (a remote pin's or auto-fetch's quota) if lower; over it + is `TooLarge`, not `InvalidDag`. Every caller gets it, since it lives in `fetch_dag`: `get`, `pin_add`, + `dir_set`/`dir_rm`, `export`, `export_stream`, follow, remote pins and LXMF auto-fetches. Why the builder's + output never comes near it: a file node child is at most 51 bytes (`[cid, size]`: 39-byte CID, 9-byte size) + and a node header at most 7; fixed chunks are 64 KiB and FastCDC chunks over 16 KiB except a file's last, + so with the levels above (at most n/255 + 8 nodes for n chunks) a file takes under 13 bytes per 4 KiB plus + under 600 bytes (its last chunk and up to 8 levels). Those 600 bytes come out of `DAG_SLACK` for the target + itself, or out of its entry's 1 KiB, which also covers the entry (at most 309 bytes with a 255-byte name), + the header of the node it points to and its share of the index nodes above. An empty file is the 0-byte raw + block and an empty directory 3 bytes, the same block each time. Counting is incremental and the allowance + only grows: a node's entries are credited when its block is counted, before anything below it, and the + chunks are counted against the declared size, so the bound holds at every block, not only at the end + (tests/security/test_dag_caps.py fetches large, FastCDC, deep, wide and empty trees, using under a third + of what it allows). What it refuses: zero-size children (audit L3), and DAGs of chunks well under 4 KiB, + which no writer following the tree rule makes. To keep the overshoot to about one block, `_take_batch` + asks only for what fits the room left after the requests in flight (`_room`, `_reserve`): a raw chunk's + declared size, a directory or index node's header (its entries bring more room than they take, while + enough entries are left under `max_output_entries`), anything else `MAX_BLOCK_SIZE`; and always one CID + when nothing is in flight. Honest fetches are not held back by this: the content still to come leaves + room for many file nodes, and directory nodes cost almost nothing. - `max_storage` is soft. An unpinned fetch evicts other cached fetches (LRU) to make room for each response and goes above `max_storage` when nothing is evictable; when its session ends the node evicts again, which may evict what it fetched if pins alone fill `max_storage`. Any fetch is refused with `StorageFull` ("not diff --git a/rnfs/features/follow.py b/rnfs/features/follow.py index 49a769b..9f48b8d 100644 --- a/rnfs/features/follow.py +++ b/rnfs/features/follow.py @@ -11,8 +11,8 @@ doubling with each further failure up to announce_interval; a failed fetch is retried with the record held, without a path request. - One background thread does every check and fetch, one at a time, so following many names never runs more - than one transfer. Fetches obey the usual limits (max_download_size, disk reserve); a followed target is - a pin, so it is provided. + than one transfer. Fetches obey the usual limits (max_download_size, the DAG byte bound of the new + target's declared size, disk reserve); a followed target is a pin, so it is provided. - Followed names count against keep_max_names. State: /follow.json (atomic). No RNS at module level (the CLI imports this module). diff --git a/rnfs/fetcher.py b/rnfs/fetcher.py index ee54be4..efc270d 100644 --- a/rnfs/fetcher.py +++ b/rnfs/fetcher.py @@ -33,7 +33,14 @@ Limits (sections 7 and 8): max_download_size bounds both the target's verified size and the DAG node bytes a fetch downloads (a DAG of empty files has size 0 but can hold gigabytes of directory nodes); listings and -write-outs stop at max_output_entries entries met while fetching. max_storage never refuses a fetch: one +write-outs stop at max_output_entries entries met while fetching. fetch_dag also bounds the bytes of the +DAG's distinct blocks by what its declared size can hold (dag_bound: the size, DAG_GRANULE_OVERHEAD per +DAG_GRANULE of it and DAG_SLACK), plus DAG_ENTRY_OVERHEAD per directory entry or index child met, for at most +max_output_entries of them; a caller's max_dag_bytes (a quota) can lower it. A file node may list children +of size 0, so without it a file declaring 1 byte could hold any number of distinct nodes. What the builder +writes never comes near it (design.md, "DAG byte bound"). While it applies, requests ask for no more than it +leaves, counting a DAG node as MAX_BLOCK_SIZE until it arrives, so a fetch downloads at most about one block +beyond it before failing with TooLarge. max_storage never refuses a fetch: one that isn't being pinned evicts other cached fetches to make room for each response, and goes over max_storage if nothing is evictable (the node evicts again once it ends). What refuses a fetch, pinned or not, with StorageFull is the disk: its remaining size, and then each response, must leave the disk reserve @@ -55,7 +62,7 @@ from typing import Callable, Iterator from rnfs.config import Config -from rnfs.constants import CHUNK_SIZE, KIB, MAX_REQUEST_SIZE +from rnfs.constants import CHUNK_SIZE, KIB, MAX_BLOCK_SIZE, MAX_REQUEST_SIZE from rnfs.content import walker from rnfs.content.cid import Cid from rnfs.content.storage import Storage, read_json, write_json @@ -74,6 +81,21 @@ MAX_BATCH = 32 # CIDs reserved for one request at most; the client sends as many as fit one packet INVALID_FILE = "invalid.json" MAX_INVALID = 10_000 # roots remembered as invalid; past this the oldest is forgotten +# The DAG byte bound (dag_bound and fetch_dag; design.md, "DAG byte bound"). Writers cut chunks of CHUNK_SIZE, +# or with FastCDC of over 16 KiB, so a file has at most one chunk per 16 KiB of content beyond its last; each +# costs at most 51 bytes of file node (a [cid, size] child), plus about 1/255 of that for the levels above +DAG_GRANULE = CHUNK_SIZE // 16 # 4 KiB of declared content, the smallest chunk SmallLeaves counts as large... +DAG_GRANULE_OVERHEAD = 64 # ...may bring this many bytes of file nodes (an honest file needs under 13) +DAG_ENTRY_OVERHEAD = 1 * KIB # bytes allowed per directory entry or index child met (an honest one needs ~830) +DAG_SLACK = 1 * KIB # the target's own node header and the file node levels above its chunks (under 600) +NODE_ENTRIES = MAX_BLOCK_SIZE // 43 # entries a directory node can hold at most (43 bytes is the smallest) + + +def dag_bound(size: int) -> int: + """The most bytes of distinct blocks the DAG of a target declaring size content bytes may hold, before + the allowance its directory entries bring (Fetch.fetch_dag): size, DAG_GRANULE_OVERHEAD per DAG_GRANULE + of it, and DAG_SLACK.""" + return size + DAG_GRANULE_OVERHEAD * -(-size // DAG_GRANULE) + DAG_SLACK class Cancelled(RnfsError): @@ -236,9 +258,11 @@ def __init__(self, fetcher: Fetcher, root: Cid, names: list[str], from_node: byt self.downloaded_bytes = 0 self.node_bytes = 0 # DAG node bytes downloaded, which max_download_size also bounds # Set by the caller to bound the bytes of the distinct blocks of the DAG met, stored or downloaded - # (a remote pin's quota); TooLarge once over + # (a remote pin's quota); TooLarge once over. fetch_dag adds the bound its target's size implies self.max_dag_bytes: int | None = None self.dag_bytes = 0 + self._declared: int | None = None # content bytes the targets of fetch_dag declare, once it runs + self._entries = 0 # directory entries and index children of the counted blocks self._counted: set[Cid] = set() self._progress_at = fetcher.clock() # last verified block or response progress, for the no-progress deadline self.root_delivered = False @@ -358,12 +382,16 @@ def check_disk(self, target: Expect, size: int): def fetch_dag(self, target: Expect, max_entries: int | None = None): """Fetch every block of target's DAG not already stored, level by level, checking each. - With max_entries, raises TooLarge once more directory entries than that are met (for write-outs). + Raises TooLarge once its distinct blocks hold more than dag_limit() bytes (what target's declared + size and the directory entries met allow, or max_dag_bytes if lower), and with max_entries once more + directory entries than that are met (for write-outs). """ with self._checking(): self.ensure([target]) size = self.size_of(target) self.check_size(size) + # From here on the DAG's blocks are counted (the target's again) against what its size can hold + self._declared = (self._declared or 0) + size if self.observer is not None: self.observer.total(size) # The root's own DAG, already complete, was checked from the same expectation when it was memoised @@ -532,17 +560,62 @@ def _check_stored(self, expect: Expect) -> list[Expect] | None: log(f"stored block {expect.cid} of {self.root} is damaged; fetching it again", LOG_WARNING) return None raise - self._count_dag(expect.cid, len(block)) + self._count_dag(expect.cid, len(block), result) return result - def _count_dag(self, cid: Cid, length: int): - """Count a block of the DAG once against max_dag_bytes, if set. Raises TooLarge once over.""" - if self.max_dag_bytes is None or cid in self._counted: + def dag_limit(self) -> int | None: + """Bytes the distinct blocks of the DAG may hold: max_dag_bytes, and once fetch_dag runs, dag_bound of + the size its target declares plus DAG_ENTRY_OVERHEAD per directory entry or index child met (at most + max_output_entries of them), whichever is lower. None if neither applies.""" + limits = [] if self.max_dag_bytes is None else [self.max_dag_bytes] + if self._declared is not None: + entries = min(self._entries, self.config.max_output_entries) + limits.append(dag_bound(self._declared) + DAG_ENTRY_OVERHEAD * entries) + return min(limits, default=None) + + def _count_dag(self, cid: Cid, length: int, children: list[Expect] = ()): + """Count a block of the DAG once against dag_limit(), if there is one, with the directory entries and + index children among its children. Raises TooLarge once over.""" + if (self.max_dag_bytes is None and self._declared is None) or cid in self._counted: return self._counted.add(cid) self.dag_bytes += length - if self.dag_bytes > self.max_dag_bytes: - raise TooLarge(f"{self.root} holds more than {self.max_dag_bytes} bytes of blocks") + self._entries += sum(1 for child in children if child.kind is not None or child.first_name is not None) + limit = self.dag_limit() + if self.dag_bytes > limit: + if limit == self.max_dag_bytes: + raise TooLarge(f"{self.root} holds more than {limit} bytes of blocks") + raise TooLarge(f"{self.root} holds more than {limit} bytes of blocks, more than its " + f"{plural(self._declared, 'byte')} of content and its directory entries can take: " + "refusing it") + + def _room(self, wanted: dict[Cid, list[Expect]]) -> tuple[int, int] | None: + """(bytes, entries) dag_limit() leaves for blocks not yet asked for, after the most the requests in + flight can bring; None if there is no limit.""" + limit = self.dag_limit() + if limit is None: + return None + room = (limit - self.dag_bytes, self.config.max_output_entries - self._entries) + for cids, _ in self._in_flight.values(): + for cid in cids: + room = self._reserve(cid, wanted, room) + return room + + def _reserve(self, cid: Cid, wanted: dict[Cid, list[Expect]], room: tuple[int, int]) -> tuple[int, int]: + """room after the most cid can add to the DAG's bytes: a raw chunk's size; for a directory or index + node only its header, while the entries left can take the most a node holds (each brings more + allowance than it takes); otherwise MAX_BLOCK_SIZE.""" + room_bytes, room_entries = room + expects = wanted.get(cid) + if cid in self._counted: + return room + if not expects: + return room_bytes - MAX_BLOCK_SIZE, room_entries + if cid.is_raw: + return room_bytes - (MAX_BLOCK_SIZE if expects[0].size is None else expects[0].size), room_entries + if room_entries >= NODE_ENTRIES and not any(walker.FILE_TYPE in expect.types for expect in expects): + return room_bytes - 8, room_entries - NODE_ENTRIES + return room_bytes - MAX_BLOCK_SIZE, room_entries def _accept(self, cid: Cid, block: bytes, wanted: dict[Cid, list[Expect]], children: dict): """Check a verified block against every expectation of it and the limits, then store it. @@ -556,7 +629,7 @@ def _accept(self, cid: Cid, block: bytes, wanted: dict[Cid, list[Expect]], child if limit is not None and self.node_bytes + len(block) > limit: raise TooLarge(f"{self.root}'s DAG nodes would exceed max_download_size ({limit})") self.node_bytes += len(block) - self._count_dag(cid, len(block)) + self._count_dag(cid, len(block), children[wanted[cid][0]]) self.session.put(cid, block) self._progress_at = self.fetcher.clock() self.downloaded_blocks += 1 @@ -683,8 +756,13 @@ def _assign(self, pending: deque, in_flight: dict, outstanding: set[Cid], wanted busy_providers += 1 def _take_batch(self, pending: deque, node_id: bytes, wanted: dict[Cid, list[Expect]]) -> list[Cid]: - """The next CIDs to ask node_id for: what its budget and one request packet can hold, at least one.""" + """The next CIDs to ask node_id for: what its budget and one request packet can hold, at least one. + + Under a DAG limit, only what the room it leaves can take for sure (_room), but at least one CID when no + request is in flight: so a fetch downloads at most about one block beyond the limit. + """ budget = rates.requester_max_bytes(self.client.rate_estimate(node_id), self.config) + room = self._room(wanted) batch: list[Cid] = [] total = 0 while pending and len(batch) < MAX_BATCH: @@ -696,6 +774,10 @@ def _take_batch(self, pending: deque, node_id: bytes, wanted: dict[Cid, list[Exp # The largest request any link allows; the client sends what fits its link and returns the rest if batch and protocol.blocks_batch_size(budget, batch + [cid], MAX_REQUEST_SIZE) <= len(batch): break + if room is not None: + room = self._reserve(cid, wanted, room) + if room[0] < 0 and (batch or self._in_flight): + break batch.append(pending.popleft()) total += guess return batch diff --git a/rnfs/node.py b/rnfs/node.py index 6b5988c..2434133 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -952,12 +952,13 @@ def _remote_pin_fetch(self, cid: Cid, source: str): f"quota ({size(used)} of {size(quota)} used)", LOG_WARNING) return # A DAG of empty files has size 0 yet can hold gigabytes of directory nodes: count its blocks + # (fetch_dag also bounds them by what the size can hold, whichever is lower) fetch.max_dag_bytes = quota - used try: fetch.fetch_dag(expect) - except TooLarge: + except TooLarge as e: log(f"dropping remote pin of {cid} for {source}: its blocks would exceed its quota " - f"({size(used)} of {size(quota)} used)", LOG_WARNING) + f"({size(used)} of {size(quota)} used) or what its size can hold: {e}", LOG_WARNING) return with self._lock: # Checked and pinned under the lock, so a release arriving now is either seen or undoes it diff --git a/rnfs/node_content.py b/rnfs/node_content.py index 4e7d4e8..c825ce1 100644 --- a/rnfs/node_content.py +++ b/rnfs/node_content.py @@ -193,7 +193,8 @@ def block(node, cid: str, for_gateway: bool = False) -> bytes: def export_stream(node, target: str, start: Callable[[int], BinaryIO | None], for_gateway: bool = False) -> dict: """Fetch the target's DAG, then write its archive to start(exact archive length): the stream to write to, - or None to write nothing (HEAD). Limits as for export: max_download_size, max_output_entries.""" + or None to write nothing (HEAD). Limits as for export: max_download_size, max_output_entries, and the DAG + byte bound of fetch_dag.""" root, names = walker.parse_target(target) with node._fetching(root, names, None, for_gateway) as fetch: expect = fetch.resolve() diff --git a/specification.md b/specification.md index 1d4fb75..c22710c 100644 --- a/specification.md +++ b/specification.md @@ -533,15 +533,28 @@ and repeat. (section 5), so a DAG can't hide its size. 4. Writing out a DAG (`get`, `ls`, the gateway, `export`) stops with an error after `max_output_entries` entries, counting every occurrence. This catches DAGs that repeat empty subtrees, which have size 0. -5. Spread requests over up to `max_parallel_providers` providers (1 on slow nodes, where parallel transfers +5. Fetching a target's whole DAG (`get`, `pin add`, `export`, `dir set` and `dir rm`, a followed name's new + target, the gateway's archives, remote pins and LXMF auto-fetches) is bounded by what its verified size can + hold. The bytes of its distinct blocks, stored or downloaded, may not exceed the size, plus 64 bytes per + 4 KiB of it (rounded up), plus 1 KiB, plus 1 KiB per directory entry or index child met (at most + `max_output_entries` of them); a remote pin's or auto-fetch's quota applies too, whichever is lower. A file + node may list children of size 0, so without this a file that declares 1 byte could hold any number of + distinct nodes. Every DAG built by the tree rule (section 5) stays far inside the bound: its file nodes take + under 13 bytes per 4 KiB of content, and each entry, with the nodes above it and the levels of a file + below it, under 1 KiB. A fetch that would exceed it fails ("too large") without marking the root invalid. + While the bound applies, a requester asks only for blocks that fit the room it leaves, counting a DAG node + not yet received as `MAX_BLOCK_SIZE` (a directory or index node as its header only, since its entries bring + more room than they take), and always for at least one block when none is in flight, so a fetch downloads + at most about one block beyond the bound. Byte-range reads are lazy and skip empty children instead. +6. Spread requests over up to `max_parallel_providers` providers (1 on slow nodes, where parallel transfers only contend for the same channel). A response *stalls* if it makes no progress for `stall_timeout`, or misses the same deadline the responder uses (section 3). A stalled provider's link is closed, which cancels the transfer, and its blocks are requested again from another provider; providers that keep failing are handled as in section 6. A fetch that fails or is cancelled closes the links of its requests still in flight, so their responses don't keep using the link and hold up the next `/blocks` to those nodes. -6. Blocks are stored as they arrive, so an interrupted fetch resumes where it stopped. An RNS Resource is only +7. Blocks are stored as they arrive, so an interrupted fetch resumes where it stopped. An RNS Resource is only delivered when complete, so a response lost mid-transfer yields nothing and its blocks are requested again. -7. When the target is complete, the node provides it if its strategy says so (section 6). +8. When the target is complete, the node provides it if its strategy says so (section 6). ## 8. Storage @@ -688,7 +701,9 @@ resolves after its owner's announce has expired from the network. **Following.** A node MAY *follow* a name (`name follow`): it keeps the name and pins its latest target with source `follow:`. When the name moves, the new target is fetched completely in the background and -pinned, and only then is the previous target's follow pin released, so a complete copy is always held. A +pinned, and only then is the previous target's follow pin released, so a complete copy is always held. The +fetch has the usual limits: `max_download_size`, the DAG byte bound of its new target's size (section 7) and +the disk reserve (section 8). A followed name is checked when a newer record is heard and every `announce_interval`. - The periodic check uses the record held, unless nothing was heard for the name for 2 × `announce_interval`; only then does it resolve through the network (a path request, then keepers). @@ -719,7 +734,7 @@ as is cheap. Rules live in the sections referenced. | Threat | Protection | |---|---| | Forged or corrupted data | Every block verified against its CID (7) | -| Size lies, repeated or empty-subtree bombs, deep trees | Verified sizes, `max_download_size`, `max_output_entries`, `MAX_DEPTH`, each block fetched once (5, 7) | +| Size lies, repeated or empty-subtree bombs, deep trees | Verified sizes, `max_download_size`, `max_output_entries`, the DAG byte bound of whole-DAG fetches, `MAX_DEPTH`, each block fetched once (5, 7) | | Malformed DAG nodes | Strict msgpack types, re-encoding and per-node checks (5). The tree rule gives honest writers one CID per content; readers don't enforce it | | Advertising on someone else's behalf | Records name only the identified requester (6) | | Advertising content it doesn't have, or stale records | Failed providers removed; the search runs again; withdrawals (6, 7) | diff --git a/tests/security/test_dag_caps.py b/tests/security/test_dag_caps.py new file mode 100644 index 0000000..723ce37 --- /dev/null +++ b/tests/security/test_dag_caps.py @@ -0,0 +1,280 @@ +"""DAG byte bound (fix for L3, N1 and L5): fetch_dag bounds the bytes of a DAG's distinct blocks by what its declared +size and the directory entries met can hold (rnfs.fetcher.dag_bound plus DAG_ENTRY_OVERHEAD per entry). + +The tests check the bound's formula; that everything the builder writes (a file of hundreds of chunks over two node +levels, FastCDC chunks, deep and wide directories with the longest names, empty files and directories) is fetched +for real by get, pin add, dir set, dir rm and export without coming near it, and without smaller requests; and that +DAGs made to break it are refused with TooLarge, every way a user can fetch one, after downloading at most about one +block more than it allows. +""" +import io +import os +import random +import unittest +from unittest import mock + +from rnfs import fetcher +from rnfs.constants import CHUNK_SIZE, MAX_BLOCK_SIZE, MAX_CHILDREN +from rnfs.content import builder, dag, walker +from rnfs.content.cid import DAG, EMPTY_FILE, RAW, Cid +from rnfs.content.storage import USER +from rnfs.errors import TooLarge +from rnfs.fetcher import DAG_ENTRY_OVERHEAD, DAG_GRANULE, DAG_GRANULE_OVERHEAD, DAG_SLACK, dag_bound +from tests.security._content_helpers import TwoNodeTest +from tests.test_node import A_ID + +LONG = 255 # the longest entry name, in bytes + + +def name(i: int, length: int = LONG) -> str: + """A distinct entry name of length bytes.""" + return f"{i:05d}".ljust(length, "n") + + +class DagBoundTest(unittest.TestCase): + """The formula.""" + + def test_formula(self): + self.assertEqual(dag_bound(0), DAG_SLACK) + self.assertEqual(dag_bound(1), 1 + DAG_GRANULE_OVERHEAD + DAG_SLACK) + self.assertEqual(dag_bound(DAG_GRANULE), DAG_GRANULE + DAG_GRANULE_OVERHEAD + DAG_SLACK) + self.assertEqual(dag_bound(DAG_GRANULE + 1), DAG_GRANULE + 1 + 2 * DAG_GRANULE_OVERHEAD + DAG_SLACK) + self.assertEqual(dag_bound(dag.MAX_SIZE) - dag.MAX_SIZE, + DAG_GRANULE_OVERHEAD * (dag.MAX_SIZE // DAG_GRANULE + 1) + DAG_SLACK) + + def test_room_for_the_largest_honest_nodes(self): + # A file node child is at most 51 bytes: a full file node of MAX_CHILDREN chunks fits the allowance of + # its chunks many times over, even at FastCDC's smallest chunk (16 KiB) + child = len(dag.encode(dag.FileNode([dag.Child(Cid.for_block(DAG, b"x"), dag.MAX_SIZE)]))) + self.assertLessEqual(child - 3, 51) + full = len(dag.encode(dag.FileNode([dag.Child(Cid.for_block(DAG, b"%d" % i), dag.MAX_SIZE) + for i in range(MAX_CHILDREN)]))) + self.assertLess(full, MAX_CHILDREN * (16 * 1024 // DAG_GRANULE) * DAG_GRANULE_OVERHEAD / 4) + # A directory entry with the longest name, and an index child, take well under DAG_ENTRY_OVERHEAD + entry = len(dag.encode(dag.DirectoryNode([dag.Entry(name(0), Cid.for_block(DAG, b"x"), dag.MAX_SIZE, 2)]))) + index = len(dag.encode(dag.IndexNode([dag.IndexChild(name(0), Cid.for_block(DAG, b"x"), dag.MAX_SIZE)]))) + self.assertLess(max(entry, index), DAG_ENTRY_OVERHEAD // 3) + + +class CapTest(TwoNodeTest): + """B fetches from A what A built or was given, counting B's /blocks requests.""" + + def setUp(self): + """Count requests as well as blocks.""" + super().setUp() + self.requests = 0 + real = self.net.blocks + + def counting(*args, **kwargs): + """NetClient.blocks, counted.""" + self.requests += 1 + return real(*args, **kwargs) + + self.net.blocks = counting + self.random = random.Random(1) + + def data(self, length: int) -> bytes: + """length reproducible random bytes.""" + return self.random.randbytes(length) + + def add_file(self, length: int, chunker: str | None = None) -> Cid: + """A file of length random bytes added on A and advertised.""" + cid = Cid.parse(self.a.add_stream(io.BytesIO(self.data(length)), chunker=chunker)["cid"]) + self.a.records.store(cid.key, A_ID) + return cid + + def add_tree(self, make) -> Cid: + """make(path) builds a tree on disk; it is added on A and advertised.""" + path = os.path.join(self.tmp.name, f"tree{self.random.random()}") + make(path) + cid = Cid.parse(self.a.add_path(path)["cid"]) + self.a.records.store(cid.key, A_ID) + return cid + + def usage(self, node, cid: Cid) -> tuple[int, int, int]: + """(declared size, bytes of distinct blocks, entries and index children counted) of a stored DAG.""" + get = node.storage.getter() + expect = walker.root_expect(cid) + total = entries = 0 + for current, block in walker.iter_dag(expect, get): + total += len(block) + if not current.cid.is_raw: + entries += sum(1 for child in walker.expand(current, block) + if child.kind is not None or child.first_name is not None) + return walker.size_of(expect, get(expect)), total, entries + + def assert_well_inside(self, cid: Cid): + """B fetched cid's DAG with get, pin add and export; its overhead takes under half what the bound allows.""" + out = os.path.join(self.tmp.name, f"out{self.random.random()}") + self.b.pin_add(str(cid)) + self.assertIn(USER, self.b.storage.pins()[cid]) + self.assertTrue(self.b.storage.is_complete(cid)) + self.b.get(str(cid), out) + self.b.export(str(cid), out + ".archive") + size, total, entries = self.usage(self.b, cid) + allowed = dag_bound(size) + DAG_ENTRY_OVERHEAD * min(entries, self.b.config.max_output_entries) + self.assertLessEqual(total - size, (allowed - size) / 2, + f"{total - size} bytes of nodes for {size} bytes and {entries} entries") + return out + + # Honest content + + def test_file_of_two_node_levels(self): + cid = self.add_file((MAX_CHILDREN + 4) * CHUNK_SIZE + 5) + self.assertEqual(self.b.info(str(cid))["size"], (MAX_CHILDREN + 4) * CHUNK_SIZE + 5) + out = self.assert_well_inside(cid) + self.assertEqual(os.path.getsize(out), (MAX_CHILDREN + 4) * CHUNK_SIZE + 5) + + def test_fastcdc_file(self): + self.assert_well_inside(self.add_file(3 * 1024 * 1024 + 7, chunker="fastcdc")) + + def test_files_just_over_a_chunk(self): + # Two chunks each: a file node per file, the most per-file overhead the fixed chunker writes + def make(path): + """Thirty such files with the longest names.""" + os.makedirs(path) + for i in range(30): + with open(os.path.join(path, name(i)), "wb") as f: + f.write(self.data(CHUNK_SIZE + 1)) + self.assert_well_inside(self.add_tree(make)) + + def test_deep_directories_with_long_names(self): + # Built in memory: 60 directories with the longest names don't fit a local path + with self.a.storage.protect() as session: + built = builder.build_bytes(self.data(CHUNK_SIZE + 1), session.put) + for depth in range(60): + entries = [dag.Entry(name(1), builder.build_bytes(b"", session.put).cid, 0, dag.KIND_FILE), + dag.Entry(name(2), builder.build_bytes(self.data(1), session.put).cid, 1, dag.KIND_FILE), + dag.Entry(name(3), built.cid, built.size, built.kind)] + built = builder.build_directory(entries, session.put, built.height) + self.a.storage.pin(built.cid, USER) + self.a.publisher.refresh() + self.assertEqual(built.height, 61) + self.a.records.store(built.cid.key, A_ID) + self.assert_well_inside(built.cid) + + def test_wide_directory_of_empty_files_and_directories(self): + def make(path): + """Over MAX_CHILDREN empty files (so index nodes) and some empty directories, longest names.""" + os.makedirs(path) + for i in range(2 * MAX_CHILDREN + 3): + with open(os.path.join(path, name(i)), "wb"): + pass + for i in range(10): + os.makedirs(os.path.join(path, name(10_000 + i))) + cid = self.add_tree(make) + self.assertIsInstance(dag.decode(self.a.storage.get(cid)), dag.IndexNode) + self.assertEqual(self.b.info(str(cid))["size"], 0) + self.assert_well_inside(cid) + + def test_empty_file_and_empty_directory(self): + empty = self.add_file(0) + self.assertEqual(empty, EMPTY_FILE) + self.assert_well_inside(empty) + directory = self.add_tree(os.makedirs) + self.assertEqual(self.a.storage.get(directory), dag.encode(dag.DirectoryNode([]))) + self.assert_well_inside(directory) + + def test_dir_set_and_dir_rm(self): + def make(path): + """A directory of a few small files.""" + os.makedirs(path) + for i in range(5): + with open(os.path.join(path, name(i, 8)), "wb") as f: + f.write(self.data(100 * i)) + directory = self.add_tree(make) + child = self.add_file(MAX_CHILDREN * CHUNK_SIZE + 1) + new = Cid.parse(self.b.dir_set(str(directory), "big", str(child))["cid"]) + self.assertTrue(self.b.storage.is_complete(new)) + removed = Cid.parse(self.b.dir_rm(f"{new}/{name(0, 8)}")["cid"]) + self.assertTrue(self.b.storage.is_complete(removed)) + + def test_honest_directories_are_fetched_in_as_few_requests(self): + def make(path): + """Forty small subdirectories of a few tiny files: many DAG nodes, little content.""" + for i in range(40): + os.makedirs(os.path.join(path, name(i, 10))) + for j in range(3): + with open(os.path.join(path, name(i, 10), name(j, 6)), "wb") as f: + f.write(self.data(10 + j)) + cid = self.add_tree(make) + self.b.pin_add(str(cid)) + bounded = self.requests + other = self.make_node("c", bytes([0xC] * 16)) + other.routing.heard(A_ID) + self.requests = 0 + with mock.patch.object(fetcher, "dag_bound", lambda size: 1 << 62): + other.pin_add(str(cid)) + self.assertEqual(bounded, self.requests) + + # Content made to break the bound + + def serve_nodes(self, nodes: list, raw: bytes = b"") -> Cid: + """Encode nodes (the last is the root), store them and the raw block on A, pin and advertise the root.""" + blocks: dict[Cid, bytes] = {EMPTY_FILE: b"", Cid.for_block(RAW, raw): raw} + cid = None + for node in nodes: + block = dag.encode(node) + cid = Cid.for_block(DAG, block) + blocks[cid] = block + self.serve(blocks, cid) + return cid + + def empty_file_nodes(self, count: int, children: int) -> list: + """count distinct file nodes of children empty chunks (about 41 bytes each), each of size 0.""" + return [dag.FileNode([dag.Child(EMPTY_FILE, 0)] * (children + i)) for i in range(count)] + + def cids(self, nodes: list) -> list[Cid]: + """The CIDs of nodes.""" + return [Cid.for_block(DAG, dag.encode(node)) for node in nodes] + + def test_small_root_over_large_empty_nodes(self): + # The root fits the bound of its 1 byte; each node below it is 80 KB of empty chunks + empties = self.empty_file_nodes(15, 2000) + x = Cid.for_block(RAW, b"x") + root = dag.FileNode([dag.Child(cid, 0) for cid in self.cids(empties)] + [dag.Child(x, 1)]) + self.assertLess(len(dag.encode(root)), dag_bound(1)) + cid = self.serve_nodes(empties + [root], b"x") + for fetch in (lambda: self.b.pin_add(str(cid)), lambda: self.b.get(str(cid), os.path.join(self.tmp.name, "o")), + lambda: self.b.export(str(cid), os.path.join(self.tmp.name, "o.archive"))): + self.net.reset() + self.b.gc() + with self.assertRaises(TooLarge) as caught: + fetch() + self.assertIn("refusing it", str(caught.exception)) + # Without holding requests to the room left, a request for 8 nodes would bring 640 KB + self.assertLessEqual(self.net.downloaded_bytes, dag_bound(1) + MAX_BLOCK_SIZE) + self.assertNotIn(cid, self.b.storage.pins()) + + def test_directory_of_empty_nodes(self): + # Each entry brings DAG_ENTRY_OVERHEAD; each points to 40 KB of empty chunks + empties = self.empty_file_nodes(200, 1000) + directory = dag.DirectoryNode([dag.Entry(name(i, 8), cid, 0, 0) for i, cid in enumerate(self.cids(empties))]) + cid = self.serve_nodes(empties + [directory]) + with self.assertRaises(TooLarge): + self.b.pin_add(str(cid)) + limit = dag_bound(0) + len(empties) * DAG_ENTRY_OVERHEAD + self.assertLessEqual(self.net.downloaded_bytes, len(dag.encode(directory)) + limit + MAX_BLOCK_SIZE) + self.assertNotIn(cid, self.b.storage.pins()) + + def test_dir_set_with_a_child_that_breaks_the_bound(self): + directory = self.add_tree(os.makedirs) + bomb = self.serve_bomb() + with self.assertRaises(TooLarge): + self.b.dir_set(str(directory), "child", str(bomb.root)) + self.assertEqual(self.b.storage.pins(), {}) + + def test_a_lower_quota_still_applies(self): + cid = self.add_file(4 * CHUNK_SIZE) + with self.assertRaises(TooLarge) as caught, self.b.fetcher.open(cid, pinned=True) as fetch: + fetch.max_dag_bytes = 2 * CHUNK_SIZE + fetch.fetch_dag(fetch.resolve()) + self.assertNotIn("refusing it", str(caught.exception)) # the quota's message: it was lower + with self.b.fetcher.open(cid, pinned=True) as fetch: + fetch.max_dag_bytes = 10 * CHUNK_SIZE + fetch.fetch_dag(fetch.resolve()) + self.assertEqual(fetch.dag_limit(), dag_bound(4 * CHUNK_SIZE)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l3_zero_size_children.py b/tests/security/test_l3_zero_size_children.py index aae980a..ea981c1 100644 --- a/tests/security/test_l3_zero_size_children.py +++ b/tests/security/test_l3_zero_size_children.py @@ -1,10 +1,11 @@ -"""L3: a file node may list children of size 0, so a file that declares 1 byte can make `get` download an unbounded +"""L3: a file node may list children of size 0, so a file that declares 1 byte could make `get` download an unbounded DAG of empty file nodes. B gets, through the real Fetcher and default config limits, a root served by A whose content is b"x" but whose DAG -holds about 2 MB of distinct file nodes made only of empty chunks. `get` downloads every one of them (and, with a -small max_storage, evicts an unrelated cached fetch to make room), while reading the same file the way `cat` does -fetches 2 blocks. The fixed behaviour bounds a `get` by the declared size plus per-node overhead. +holds about 2 MB of distinct file nodes made only of empty chunks. Before the fix `get` downloaded every one of them +(and, with a small max_storage, evicted an unrelated cached fetch to make room). fetch_dag now bounds the DAG's +bytes by what the declared size can hold (rnfs.fetcher.dag_bound), so the get is refused with TooLarge after the +root, and reading the same file the way `cat` does still fetches 2 blocks. """ import io import os @@ -12,7 +13,7 @@ from rnfs.constants import CHUNK_SIZE from rnfs.content.cid import Cid -from rnfs.errors import RnfsError +from rnfs.errors import RnfsError, TooLarge from tests.security._content_helpers import SLACK, TwoNodeTest from tests.test_node import A_ID @@ -38,28 +39,24 @@ def honest_cached(self) -> bool: cid = Cid.parse(self.honest) return cid in self.b.storage.cached_targets() and self.b.storage.is_complete(cid) - def test_get_downloads_every_empty_file_node(self): + def test_get_is_refused_without_evicting_the_cache(self): self.assertEqual(self.b.info(str(self.bomb.root))["size"], 1) # what info and the gateway show out = os.path.join(self.tmp.name, "out") - self.b.get(str(self.bomb.root), out) - with open(out, "rb") as f: - self.assertEqual(f.read(), b"x") - # Every distinct block of the DAG came over the network (the root for info, the rest for get) to write - # 1 byte: about 2 MB - self.assertEqual(self.net.downloaded_blocks, len(self.bomb.blocks)) - self.assertEqual(self.net.downloaded_bytes, self.bomb.total_bytes()) - self.assertGreater(self.net.downloaded_bytes, 2_000_000) # over 8 times what the fixed bound allows - self.assertGreater(self.net.downloaded_bytes, 8 * (self.bomb.size + SLACK)) - # Room for it was made by evicting the unrelated cached fetch - self.assertFalse(self.honest_cached()) + with self.assertRaises(TooLarge) as caught: + self.b.get(str(self.bomb.root), out) + self.assertIn("refusing it", str(caught.exception)) + self.assertFalse(os.path.lexists(out)) + # Only the root came over the network (for info): none of the empty file nodes below it + self.assertEqual(self.net.downloaded_blocks, 1) + self.assertLess(self.net.downloaded_bytes, self.bomb.size + SLACK) + # Nothing needed room, so the unrelated cached fetch is still there + self.assertTrue(self.honest_cached()) def test_read_range_fetches_two_blocks(self): self.assertEqual(b"".join(self.b.read(str(self.bomb.root))), b"x") self.assertEqual(self.net.downloaded_blocks, 2) # the root and the 1-byte chunk: empty subtrees skipped self.assertTrue(self.honest_cached()) - # L3: fails until a get of a root declaring N bytes downloads at most about N bytes plus per-node overhead - @unittest.expectedFailure def test_get_downloads_about_the_declared_size(self): out = os.path.join(self.tmp.name, "out") try: diff --git a/tests/security/test_l5_follow_cap.py b/tests/security/test_l5_follow_cap.py new file mode 100644 index 0000000..4369651 --- /dev/null +++ b/tests/security/test_l5_follow_cap.py @@ -0,0 +1,96 @@ +"""L5: when a followed name moves, the new target is fetched and pinned with no size cap, so a hostile or compromised +name owner could point it at a DAG of any size. + +B follows A's name. A first publishes an honest file, then points the name at a root that declares 1 byte but holds +about 2 MB of empty file nodes (the L3 bomb). The follow fetch now runs under the same DAG byte bound as every +fetch_dag (rnfs.fetcher.dag_bound of the new target's declared size), so the check fails with "refusing it" after +the root, the old target stays pinned and the bomb is not. Content size itself stays bounded by max_download_size +(no default) and the disk reserve, as before: a per-follow size limit was not added. +""" +import io +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.content.cid import Cid +from rnfs.content.storage import USER +from rnfs.features.follow import follow_source +from rnfs.node import RnfsNode +from tests.security._content_helpers import SLACK, Bomb +from tests.test_names import FakeNetwork +from tests.test_names_keep import KeeperNet + +A_ID, B_ID = bytes([0xA] * 16), bytes([0xB] * 16) + + +class FollowCapTest(unittest.TestCase): + """Owner A and follower B as offline nodes.""" + + def setUp(self): + """Two nodes that know each other, and A's name.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.net = KeeperNet() + self.net.sender = B_ID + self.a = self.make_node("a", A_ID) + self.b = self.make_node("b", B_ID) + self.a.routing.heard(B_ID) + self.b.routing.heard(A_ID) + self.name = bytes.fromhex(self.a.name_create("site")["name"]) + self.downloaded = 0 + real = self.net.blocks + + def counting(node_id, cids, on_progress=None): + """KeeperNet.blocks, adding up the bytes returned.""" + result = real(node_id, cids, on_progress) + self.downloaded += sum(len(block) for block in result.blocks.values()) + return result + + self.net.blocks = counting + + def make_node(self, label: str, node_id: bytes) -> RnfsNode: + """An offline node with its own directory.""" + config = Config(rnfs_dir=os.path.join(self.tmp.name, label), slow="no", lookup_timeout=5, path_timeout=0.2) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork()) + self.net.nodes[node_id] = node + self.addCleanup(node.stop) + return node + + def test_follow_refuses_a_target_its_size_cant_hold(self): + honest = Cid.parse(self.a.add_stream(io.BytesIO(b"version one"))["cid"]) + self.a.name_publish("site", str(honest)) + self.a.records.store(honest.key, A_ID) + follow = self.b.feature("follow") + follow.follow(self.name) + self.b.names.offer(self.name, 1, honest, 0.0) # as if B heard A's announce + self.assertTrue(follow.check(self.name, network=False)) + source = follow_source(self.name) + self.assertEqual(self.b.storage.pins().get(honest), {source}) + # The name moves to the bomb + bomb = Bomb() + with self.a.storage.protect() as session: + for cid, block in bomb.blocks.items(): + session.put(cid, block) + self.a.storage.pin(bomb.root, USER) + self.a.publisher.refresh() + self.a.records.store(bomb.root.key, A_ID) + self.a.name_publish("site", str(bomb.root)) + self.b.names.offer(self.name, 2, bomb.root, 0.0) + self.downloaded = 0 + self.assertFalse(follow.check(self.name, network=False)) + self.assertIn("refusing it", follow.following()[0]["state"]) + self.assertLessEqual(self.downloaded, bomb.size + SLACK) + pins = self.b.storage.pins() + self.assertEqual(pins.get(honest), {source}) + self.assertNotIn(bomb.root, pins) + self.assertEqual(follow.following()[0]["pinned"], str(honest)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_n1_user_pin_unbounded.py b/tests/security/test_n1_user_pin_unbounded.py index 04c0ec9..aaf1c96 100644 --- a/tests/security/test_n1_user_pin_unbounded.py +++ b/tests/security/test_n1_user_pin_unbounded.py @@ -1,14 +1,13 @@ -"""N1 (core part): a user-path pin (`node.pin_add`, which `rnfs pin add` runs as a job) sets no max_dag_bytes, -unlike a remote /pin or an LXMF auto-fetch, so pinning a root that declares 1 byte downloads and pins its whole DAG. +"""N1 (core part): a user-path pin (`node.pin_add`, which `rnfs pin add` runs as a job) set no max_dag_bytes, unlike +a remote /pin or an LXMF auto-fetch, so pinning a root that declares 1 byte downloaded and pinned its whole DAG. B pins a root served by A whose content is b"x" but whose DAG holds about 2 MB of distinct file nodes made only of -empty chunks (as in L3): every block is downloaded and kept under the "user" pin. The fixed behaviour bounds the DAG -bytes of a user pin by a cap derived from the declared size, as the remote /pin path already does with its quota. +empty chunks (as in L3). fetch_dag now bounds the DAG bytes of every fetch, a user pin's included, by what the +declared size can hold (rnfs.fetcher.dag_bound), so the pin is refused with TooLarge and nothing is pinned. """ import unittest -from rnfs.content.storage import USER -from rnfs.errors import RnfsError +from rnfs.errors import RnfsError, TooLarge from tests.security._content_helpers import SLACK, TwoNodeTest @@ -20,19 +19,16 @@ def setUp(self): super().setUp() self.bomb = self.serve_bomb() - def test_pin_add_downloads_and_pins_every_empty_file_node(self): + def test_pin_add_is_refused_and_pins_nothing(self): self.assertEqual(self.b.info(str(self.bomb.root))["size"], 1) # what `rnfs stat` and pages check - self.b.pin_add(str(self.bomb.root)) - self.assertIn(USER, self.b.storage.pins()[self.bomb.root]) - self.assertEqual(self.net.downloaded_blocks, len(self.bomb.blocks)) - self.assertEqual(self.net.downloaded_bytes, self.bomb.total_bytes()) - self.assertGreater(self.net.downloaded_bytes, 8 * (self.bomb.size + SLACK)) - # All of it is kept for good, and provided - self.assertEqual(self.b.storage.pinned_bytes(), self.bomb.total_bytes()) - self.assertIn(self.bomb.root, self.b.publisher.provided()) - - # N1: fails until pin_add caps a pin's DAG bytes by a bound derived from the root's declared size - @unittest.expectedFailure + with self.assertRaises(TooLarge) as caught: + self.b.pin_add(str(self.bomb.root)) + self.assertIn("refusing it", str(caught.exception)) + self.assertNotIn(self.bomb.root, self.b.storage.pins()) + self.assertNotIn(self.bomb.root, self.b.publisher.provided()) + self.assertEqual(self.net.downloaded_blocks, 1) # the root, for info + self.assertEqual(self.b.storage.pinned_bytes(), 0) + def test_pin_add_dag_bytes_are_bounded_by_the_declared_size(self): try: self.b.pin_add(str(self.bomb.root)) From 7640d0bd03371ad8ccfbd2672cae74ae4fd1e121 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:31:33 +1030 Subject: [PATCH 012/107] Keep the disk reserve free when get and export write their output --- CHANGELOG.md | 3 ++ design.md | 5 ++- rnfs/content/storage.py | 7 +++- rnfs/node.py | 31 ++++++++++----- rnfs/node_content.py | 9 +++-- specification.md | 3 ++ tests/security/test_l9_get_free_space.py | 49 ++++++++++++++++++------ 7 files changed, 82 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dea99d..69b5f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Fixes: amount of data. Content built by `add` (either chunker) stays far inside the bound, and fetches of it ask for blocks as before. Requests under the bound never ask for more than it leaves, so a refused fetch downloads at most about one block too many. +- `get` and `export` refuse output that would leave less than the disk reserve (max(256 MiB, 2%)) free, and + `get` counts the blocks it still has to fetch when they go to the same disk. They used to check only that + the output fits, so a `get` could fill the disk. ## 0.1.3 diff --git a/design.md b/design.md index 85b80b7..b6e213b 100644 --- a/design.md +++ b/design.md @@ -465,7 +465,10 @@ Everything in the specification is implemented, with these simplifications and a may evict what it fetched if pins alone fill `max_storage`. Any fetch is refused with `StorageFull` ("not enough free disk space") if its remaining size, the range's size, or a response would leave less than max(256 MiB, 2%) of the blocks' filesystem free (`Storage.check_disk`, `DISK_RESERVE_MIN`/`_FRACTION`). - `get` and `export` also check free disk space at the destination first. + `get` and `export` also check free disk space at the destination first (`node._check_free_space`): the + output must leave the same reserve free there, and when the destination is on the blocks' filesystem, `get` + (and a sealed file's write-out) counts what it still has to fetch too (the verified size less the bytes of + the target's DAG already stored). - Roots found invalid are remembered in `state/invalid.json` (at most 10 000, oldest dropped), and there is no command to clear them. Cache records are capped at 10 000 (LRU). Both are saved every 30 s and at stop. - A remote pin's usage is the larger of its content size and its DAG's block bytes. It is dropped once the diff --git a/rnfs/content/storage.py b/rnfs/content/storage.py index 38a41c9..72f10f6 100644 --- a/rnfs/content/storage.py +++ b/rnfs/content/storage.py @@ -48,6 +48,11 @@ _IDENTITY_HASH = re.compile(r"[0-9a-f]{32}") +def disk_reserve(total: int) -> int: + """The free space a filesystem of total bytes must keep: max(DISK_RESERVE_MIN, DISK_RESERVE_FRACTION of it).""" + return max(DISK_RESERVE_MIN, int(total * DISK_RESERVE_FRACTION)) + + def check_source(source: str): """Raise ValueError unless source is a valid pin source.""" if source in (USER, FOLDER): @@ -675,7 +680,7 @@ def check_disk(self, nbytes: int): usage = shutil.disk_usage(self.blocks.directory) except OSError: return # can't tell; writing the blocks reports a real failure - reserve = max(DISK_RESERVE_MIN, int(usage.total * DISK_RESERVE_FRACTION)) + reserve = disk_reserve(usage.total) if nbytes > usage.free - reserve: raise StorageFull(f"not enough free disk space: {size(nbytes)} more would leave less than " f"{size(reserve)} free in {self.blocks.directory} ({size(usage.free)} free)") diff --git a/rnfs/node.py b/rnfs/node.py index 2434133..8f18dc0 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -31,7 +31,7 @@ from rnfs import node_content # --- content: add --no-pin/--to/--chunker, sealed files, gateway formats --- from rnfs.content import chunker as content_chunker from rnfs.content.shared_folder import SharedFolder -from rnfs.content.storage import FOLDER, USER, Storage, read_json, remote_source, write_json +from rnfs.content.storage import FOLDER, USER, Storage, disk_reserve, read_json, remote_source, write_json from rnfs.dht.lookup import Lookup from rnfs.dht.providers import ProviderStore from rnfs.dht.routing import RoutingTable @@ -91,15 +91,23 @@ def _make_private_dirs(config: Config): os.makedirs(directory, mode=0o700, exist_ok=True) -def _check_free_space(path: str, needed: int): - """Raise TooLarge if needed bytes won't fit in the free space of the filesystem path is to be written on.""" +def _check_free_space(path: str, needed: int, blocks_dir: str | None = None, to_fetch: int = 0): + """Raise TooLarge unless writing needed bytes to path leaves the disk reserve free on its filesystem. + + If blocks_dir (where a fetch stores its blocks) is on the same filesystem, the to_fetch bytes still to be + fetched there before the write count too: the fetch keeps the reserve, and the write-out must as well. + """ directory = os.path.dirname(os.path.abspath(path)) try: - free = shutil.disk_usage(directory).free + usage = shutil.disk_usage(directory) + if blocks_dir is not None and to_fetch > 0 and os.stat(directory).st_dev == os.stat(blocks_dir).st_dev: + needed += to_fetch except OSError: return # a missing or unusable directory is reported by the write itself - if needed > free: - raise TooLarge(f"{size(needed)} won't fit in the {size(free)} free in {directory}") + reserve = disk_reserve(usage.total) + if needed > usage.free - reserve: + raise TooLarge(f"{size(needed)} won't fit in the {size(usage.free)} free in {directory} " + f"while leaving {size(reserve)} free") def _remove_partial(path: str): @@ -708,11 +716,16 @@ def get(self, target: str, output: str, from_node: bytes | None = None, raw: boo try: with self._fetching(root, names, from_node) as fetch: expect = fetch.resolve() - # Its verified size is what writing it out takes, however few blocks it is made of - _check_free_space(output, fetch.size_of(expect)) + # Its verified size is what writing it out takes, however few blocks it is made of; what is + # still to be fetched is stored first, on the same disk if the blocks are there + content = fetch.size_of(expect) + to_fetch = 0 if self.storage.is_complete(expect.cid) else \ + max(0, content - self.storage.dag_bytes(expect.cid)) + _check_free_space(output, content, self.config.blocks_dir, to_fetch) sealed = None if raw else node_content.open_sealed(self, fetch, expect) if sealed is not None: # decrypted as it is fetched (rnfs/content/seal.py) - result = {"cid": str(expect.cid), "warnings": [], **node_content.write_sealed(sealed, partial)} + written = node_content.write_sealed(sealed, partial, self.config.blocks_dir, to_fetch) + result = {"cid": str(expect.cid), "warnings": [], **written} else: fetch.fetch_dag(expect, self.config.max_output_entries) warnings = walker.write_out(expect, fetch.get, partial, self.config.max_output_entries) diff --git a/rnfs/node_content.py b/rnfs/node_content.py index c825ce1..20f24c8 100644 --- a/rnfs/node_content.py +++ b/rnfs/node_content.py @@ -147,11 +147,14 @@ def sealed_name(sealed: seal.SealedFile) -> str | None: return fsutil.safe_default_name(sealed.name, None) -def write_sealed(sealed: seal.SealedFile, output: str) -> dict: - """Decrypt a sealed file to output (created exclusively), removing it again if anything fails.""" +def write_sealed(sealed: seal.SealedFile, output: str, blocks_dir: str | None = None, to_fetch: int = 0) -> dict: + """Decrypt a sealed file to output (created exclusively), removing it again if anything fails. + + Refused (TooLarge) unless it leaves the disk reserve free, counting the to_fetch bytes still to be stored + in blocks_dir if that is on the same filesystem (rnfs.node._check_free_space).""" from rnfs.node import _check_free_space, _remove_created # rnfs.node imports this module - _check_free_space(output, sealed.size) + _check_free_space(output, sealed.size, blocks_dir, to_fetch) name = sealed_name(sealed) # before creating output, so nothing raising here leaves a file behind try: out = open(output, "xb") diff --git a/specification.md b/specification.md index c22710c..83c8a4f 100644 --- a/specification.md +++ b/specification.md @@ -592,6 +592,9 @@ and repeat. - The disk is the hard bound: a fetch (pinned or not) is refused if what it still has to store — its verified size, or the range's size, before downloading, then each response — would leave less than max(256 MiB, 2% of the filesystem) free on the filesystem holding `/blocks/`. +- `get` and `export` write their output only if it leaves the same reserve (of the output's filesystem) free. + When that is the filesystem holding the blocks, what `get` still has to fetch counts too: its verified size + less the bytes of the target's DAG already stored. **Shared folder.** Every `scan_interval`, files and directories in `/files/` are imported as with `add` (copied into the block store) and pinned with source `folder`. A changed or removed file releases its diff --git a/tests/security/test_l9_get_free_space.py b/tests/security/test_l9_get_free_space.py index ea06d4b..f0f7001 100644 --- a/tests/security/test_l9_get_free_space.py +++ b/tests/security/test_l9_get_free_space.py @@ -1,9 +1,10 @@ -"""L9: `get` checks the free space of the output's filesystem without the disk reserve, so a get can fill the disk to -nothing free (the block store keeps the reserve; the write-out doesn't). +"""L9: `get` checked the free space of the output's filesystem without the disk reserve, so a get could fill the disk +to nothing free (the block store keeps the reserve; the write-out didn't). -A node gets a file it already holds completely (so nothing is fetched) while the disk, by a mock, has just 1 byte -more free than the file needs: the get goes ahead, although writing the file leaves 1 byte free. The fixed behaviour -refuses a get whose output wouldn't leave the disk reserve free. +A node gets a file it already holds completely (so nothing is fetched) while the disk, by a mock, has just under the +file's size plus the reserve free: the get is refused, where it used to go ahead and leave 1 byte free. With the +reserve to spare it goes ahead. A node fetching the file first, onto the same disk, also needs room for the blocks +still to fetch: it is refused before fetching anything below the root. """ import io import os @@ -15,10 +16,12 @@ import RNS from rnfs.config import Config -from rnfs.constants import CHUNK_SIZE +from rnfs.constants import CHUNK_SIZE, KIB from rnfs.content import storage as storage_module +from rnfs.content.cid import Cid from rnfs.errors import StorageFull, TooLarge from rnfs.node import RnfsNode +from tests.security._content_helpers import TwoNodeTest from tests.test_names import FakeNetwork from tests.test_node import A_ID, NetClient @@ -46,13 +49,11 @@ def disk(self, free: int): usage = shutil.disk_usage(self.tmp.name)._replace(total=1024 ** 3, free=free) return mock.patch("shutil.disk_usage", return_value=usage) - def test_get_leaves_less_than_the_reserve_free(self): - with self.disk(FILE_SIZE + 1): + def test_get_leaving_the_reserve_free_goes_ahead(self): + with self.disk(FILE_SIZE + storage_module.DISK_RESERVE_MIN): self.node.get(self.cid, self.out) - self.assertEqual(os.path.getsize(self.out), FILE_SIZE) # written, leaving 1 byte free + self.assertEqual(os.path.getsize(self.out), FILE_SIZE) - # L9: fails until get's free-space check requires the size plus the disk reserve - @unittest.expectedFailure def test_get_keeps_the_disk_reserve_free(self): with self.disk(FILE_SIZE + storage_module.DISK_RESERVE_MIN - 1): with self.assertRaises((TooLarge, StorageFull), msg="get would leave less than the reserve free"): @@ -60,5 +61,31 @@ def test_get_keeps_the_disk_reserve_free(self): self.assertFalse(os.path.lexists(self.out)) +class FetchingGetFreeSpaceTest(TwoNodeTest): + """B gets a 192 KiB file from A; its blocks and the output share one (mock, 1 GiB) disk.""" + + def setUp(self): + """A serves the file.""" + super().setUp() + self.cid = self.a.add_stream(io.BytesIO(os.urandom(FILE_SIZE)))["cid"] + self.a.records.store(Cid.parse(self.cid).key, A_ID) + self.out = os.path.join(self.tmp.name, "out") + + def disk(self, free: int): + """A mock disk_usage reporting free bytes on a 1 GiB filesystem (reserve: DISK_RESERVE_MIN).""" + usage = shutil.disk_usage(self.tmp.name)._replace(total=1024 ** 3, free=free) + return mock.patch("shutil.disk_usage", return_value=usage) + + def test_get_counts_the_blocks_still_to_fetch(self): + # The blocks still to fetch are estimated as the size less the bytes stored (here the root node's) + with self.disk(2 * FILE_SIZE + storage_module.DISK_RESERVE_MIN - KIB): + with self.assertRaises(TooLarge): + self.b.get(self.cid, self.out) + self.assertFalse(os.path.lexists(self.out)) + self.assertEqual(self.b.storage.block_count(), 1) # refused before fetching below the root + with self.disk(2 * FILE_SIZE + storage_module.DISK_RESERVE_MIN): + self.b.get(self.cid, self.out) + self.assertEqual(os.path.getsize(self.out), FILE_SIZE) + if __name__ == "__main__": unittest.main() From 63ebae98dbf0d1d46832ba22dc733596655f363c Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:31:33 +1030 Subject: [PATCH 013/107] Accept only hex digits in names given as text --- CHANGELOG.md | 2 ++ rnfs/names.py | 10 ++++------ tests/security/test_t1_parse_name_whitespace.py | 12 +++++------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b5f14..670a80a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Fixes: - `get` and `export` refuse output that would leave less than the disk reserve (max(256 MiB, 2%)) free, and `get` counts the blocks it still has to fetch when they go to the same disk. They used to check only that the output fits, so a `get` could fill the disk. +- Names given as text must be exactly 32 hex digits (optionally in ``, with spaces around): + whitespace between the digits used to be skipped, giving a name shorter than 16 bytes. ## 0.1.3 diff --git a/rnfs/names.py b/rnfs/names.py index ef19c3f..130c05f 100644 --- a/rnfs/names.py +++ b/rnfs/names.py @@ -19,7 +19,7 @@ from RNS.vendor import umsgpack from rnfs import fsutil -from rnfs.config import Config +from rnfs.config import _HEX_ID, Config # _HEX_ID: exactly 32 hex digits, as identity lists are checked from rnfs.constants import APP_NAME, ID_LENGTH, NAME_ASPECT from rnfs.content.cid import Cid from rnfs.errors import NotFound, RnfsError @@ -80,12 +80,10 @@ def parse_name(text: str) -> bytes: if not isinstance(text, str): raise ValueError("name must be a string") stripped = text.strip().strip("<>") - if len(stripped) != NAME_LENGTH * 2: + # Hex digits only: bytes.fromhex alone would skip whitespace between them, giving a shorter name + if not _HEX_ID.fullmatch(stripped): raise ValueError(f"name must be {NAME_LENGTH * 2} hex characters: {text!r}") - try: - return bytes.fromhex(stripped) - except ValueError: - raise ValueError(f"name must be {NAME_LENGTH * 2} hex characters: {text!r}") from None + return bytes.fromhex(stripped) def check_alias(alias) -> str: diff --git a/tests/security/test_t1_parse_name_whitespace.py b/tests/security/test_t1_parse_name_whitespace.py index c97384f..8418ab3 100644 --- a/tests/security/test_t1_parse_name_whitespace.py +++ b/tests/security/test_t1_parse_name_whitespace.py @@ -1,9 +1,9 @@ -"""T1: names.parse_name accepts whitespace between the hex digits, returning a name shorter than 16 bytes. +"""T1: names.parse_name accepted whitespace between the hex digits, returning a name shorter than 16 bytes. -parse_name checks that the text is 32 characters long, then decodes it with bytes.fromhex, which skips -whitespace between byte pairs, so "00 00 ... 00" (32 characters, 11 bytes) passes as a name. Found by -tests/security/test_fuzz_parsers.py. The test gives parse_name such text and asserts the safe behaviour: a -ValueError, as for any other text that isn't 32 hex characters. +parse_name checked that the text is 32 characters long, then decoded it with bytes.fromhex, which skips +whitespace between byte pairs, so "00 00 ... 00" (32 characters, 11 bytes) passed as a name. Found by +tests/security/test_fuzz_parsers.py. parse_name now requires exactly 32 hex digits (config's _HEX_ID); the test +gives it such text and asserts a ValueError, as for any other text that isn't 32 hex characters. """ import unittest @@ -13,8 +13,6 @@ class ParseNameWhitespaceTest(unittest.TestCase): """parse_name must reject anything but 32 hex digits (with optional ).""" - # T1: fails until parse_name checks the text is hex digits only (like contacts.parse_hex's regex) - @unittest.expectedFailure def test_inner_whitespace_is_rejected(self): texts = ["00 " * 10 + "00", "00112233445566 77 8899aabbccddee", "<00112233445566\t77\n8899aabbccddee>"] self.assertTrue(all(len(text.strip("<>")) == 32 for text in texts)) From 641b9b2f372f6632b9d6afe61c71f7e752d725b7 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:32:18 +1030 Subject: [PATCH 014/107] Keep a newcomer pool in the provider store and grow each publisher's share with age --- CHANGELOG.md | 12 + design.md | 17 +- rnfs/constants.py | 1 + rnfs/dht/providers.py | 307 +++++++++++++++---- rnfs/network/protocol.py | 8 +- rnfs/node.py | 2 +- specification.md | 21 +- tests/security/test_m4_provider_admission.py | 154 +++++++++- 8 files changed, 441 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9f83a..2344178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ [specification.md](specification.md) is the authority on behaviour. +## Unreleased + +DHT and names: +- A full provider store keeps a tenth of its room as a newcomer pool, with random eviction inside it, so a + publisher it hasn't seen can still store records; about 100 identities filling the store used to lock out + every new publisher. Publishers seen before still displace the newest one outside the pool, and the node's + own records are always accepted (they were refused once the store was full). A publisher's share of the + store grows with age: a tenth of it until it has been seen for `provider_seniority`. First-seen times are + kept for a week after a publisher's last record goes, so one that was away for a while isn't new again. +- A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own + client never sends more. + ## 0.1.3 Joining the DHT: diff --git a/design.md b/design.md index 8369bd3..1783cb1 100644 --- a/design.md +++ b/design.md @@ -150,12 +150,17 @@ packaging/ requirements.lock (hash-pinned runtime dependencies), upda (True if dropped), `remove(node_id)`, `ban(node_id)`, `is_banned(node_id)`, `closest(key, count=K, for_reply=False)`, `seniority(node_id) -> float | None` (seconds in the table), `contains(node_id)`, `node_count()`. -`providers.py` — `ProviderStore(max_records, state_path, ttl=PROVIDER_TTL, clock=time.time, rng=random)`: -`store(key, publisher) -> bool`, `withdraw(key, publisher)`, `for_reply(key, exclude=lambda id: False) -> list[bytes]`, -`expire()`, `record_count()`, `save()`. Local limits against flooding: one publisher holds at most max(10, -`max_records` // 100) records (not the node's own, `own_id`), and a key keeps at most 1000 publishers: a -newcomer to a full key evicts a random one, never one of the `K/2` oldest records. Times later than now (a -clock corrected backwards) are clamped to now at load and at each `expire()`. +`providers.py` — `ProviderStore(max_records, state_path, ttl=PROVIDER_TTL, clock=time.time, rng=random, +own_id=None, seniority=0)`: `store(key, publisher) -> bool`, `withdraw(key, publisher)`, +`for_reply(key, exclude=lambda id: False) -> list[bytes]`, `expire()`, `record_count()`, `allowance(publisher)`, +`save()`. Local limits against flooding: one publisher holds at most max(10, `max_records` // 100) records once +seen for `seniority` (the node passes `provider_seniority`) and a tenth of that (at least 10) before, the +node's own (`own_id`) excepted; a key keeps at most 1000 publishers: a newcomer to a full key evicts a random +one, never one of the `K/2` oldest records. A tenth of the store is a newcomer pool with random eviction: the +pooled records sit in a list for O(1) random picks, and the newest publisher with records outside the pool comes +from a heap of (first seen, arrival) with stale entries dropped lazily. First-seen times of departed publishers +(seen for `seniority`) are kept for a TTL, at most `max_records` // 10, in departure order. Times later than now +(a clock corrected backwards) are clamped to now at load and at each `expire()`. `lookup.py` — network access is injected: `FindQuery = Callable[[node_id, key], tuple[list[bytes], list[bytes]] | None]` returning (providers, nodes). diff --git a/rnfs/constants.py b/rnfs/constants.py index 1f44d01..1f380b2 100644 --- a/rnfs/constants.py +++ b/rnfs/constants.py @@ -23,6 +23,7 @@ ID_LENGTH = 16 # node IDs and DHT keys: RNS destination hashes, 128 bits BUCKET_COUNT = 128 RESPONSE_OVERHEAD = 1 * KIB # room left for msgpack framing in a /blocks response (section 7) +MAX_PROVIDE_KEYS = 55 # keys in one /provide, both lists: the most that fit MAX_REQUEST_SIZE (section 4) # Local, not protocol: a file being written out (.rnfs-get-.partial, .rnfs-job-.partial), then renamed PARTIAL_SUFFIX = ".partial" diff --git a/rnfs/dht/providers.py b/rnfs/dht/providers.py index 15c8b6f..20cd23a 100644 --- a/rnfs/dht/providers.py +++ b/rnfs/dht/providers.py @@ -1,24 +1,32 @@ """Provider records stored for other nodes (specification section 6). No RNS dependency. A record says "publisher provides key". Records expire PROVIDER_TTL after they were last stored and keep -their first-stored time across republishes. At most max_records are kept: when full, records of the -publisher first seen most recently go first, so a flood of new identities displaces only itself. - -Two local limits, not part of the protocol, bound what one identity can take: a publisher holds at most -max_records // PUBLISHER_SHARE records (so one long-lived identity can't fill the store and lock everyone -else out; this node's own records are exempt), and a key has at most MAX_PUBLISHERS_PER_KEY publishers (so -/find replies stay cheap however many identities advertise one key). A newcomer to a full key evicts one of -its publishers at random, never one of the K/2 oldest records: refusing newcomers would let early Sybil -identities hold the key, and the random half of /find replies relies on newcomers getting stored. +their first-stored time across republishes. At most max_records are kept, a tenth of them in a newcomer +pool. When the rest is full, a publisher seen before evicts a record of the publisher first seen most +recently, so a flood of new identities can't displace older publishers; a publisher not seen before (or the +one seen most recently) stores into the pool instead, evicting a random pooled record when it is full, so +a new publisher can always store something. This node's own records are always accepted. + +Local limits, not part of the protocol, bound what one identity can take: a publisher holds at most +max_records // PUBLISHER_SHARE records once it has been seen for `seniority`, and a tenth of that before (so +one long-lived identity can't fill the store and lock everyone else out, and a fresh one gets little; this +node's own records are exempt); and a key has at most MAX_PUBLISHERS_PER_KEY publishers (so /find replies +stay cheap however many identities advertise one key). A newcomer to a full key evicts one of its publishers +at random, never one of the K/2 oldest records: refusing newcomers would let early Sybil identities hold the +key, and the random half of /find replies relies on newcomers getting stored. A publisher's first-seen time +is kept for a TTL after its last record goes, so one that was away for a while isn't new again. Times are wall clock and persisted, so a time in the future (the clock ran ahead, then was corrected) is clamped to now on load and at each expire(); otherwise such records would outlive their TTL. """ +import heapq +import itertools import json import os import random import threading import time +from collections import OrderedDict from collections.abc import Callable, Iterable from rnfs import fsutil @@ -29,7 +37,10 @@ _EXPIRE_INTERVAL = 60 # when full, scan for expired records at most this often (seconds), not on every store PUBLISHER_SHARE = 100 # a publisher may hold at most max_records // PUBLISHER_SHARE records ... MIN_PER_PUBLISHER = 10 # ... but at least this many, so small stores still work +NEWCOMER_SHARE = 10 # until seen for `seniority`, a publisher may hold a tenth of that (at least MIN_PER_PUBLISHER) +POOL_SHARE = 10 # max_records // POOL_SHARE records are a newcomer pool, with random eviction inside it MAX_PUBLISHERS_PER_KEY = 1000 # publishers recorded per key; a newcomer evicts a random non-senior one +DEPARTED_SHARE = 10 # first-seen times kept for publishers with no records left: at most max_records // this def _never(node_id: bytes) -> bool: @@ -38,25 +49,29 @@ def _never(node_id: bytes) -> bool: class _Record: - """When a record was first stored and last stored (republished).""" + """When a record was first stored and last stored (republished), and whether it is in the newcomer pool.""" - __slots__ = ("first_stored", "last_stored") + __slots__ = ("first_stored", "last_stored", "pooled") - def __init__(self, first_stored: float, last_stored: float): + def __init__(self, first_stored: float, last_stored: float, pooled: bool = False): """Record stored at first_stored, last refreshed at last_stored.""" self.first_stored = first_stored self.last_stored = last_stored + self.pooled = pooled class _Publisher: - """A publisher with records here: when it was first seen and which keys it provides.""" + """A publisher with records here: when it was first seen, which keys it provides, and how many of its + records are outside the pool. `order` breaks first-seen ties (the one stored later is newer).""" - __slots__ = ("first_seen", "keys") + __slots__ = ("first_seen", "order", "keys", "main") - def __init__(self, first_seen: float): + def __init__(self, first_seen: float, order: int): """Publisher first seen at first_seen, with no keys yet.""" self.first_seen = first_seen + self.order = order self.keys: set[bytes] = set() + self.main = 0 class ProviderStore: @@ -64,31 +79,47 @@ class ProviderStore: `clock` is wall-clock seconds, since times are persisted. `state_path` (None: memory only) is where save() writes records and first-seen times as JSON; it is loaded on construction. `rng` picks the - random half of /find replies and the publisher a full key evicts. + random half of /find replies, the publisher a full key evicts and the pooled record a newcomer evicts. """ def __init__(self, max_records: int, state_path: str | None, ttl: float = PROVIDER_TTL, clock: Callable[[], float] = time.time, rng: random.Random = random, max_per_publisher: int | None = None, max_per_key: int = MAX_PUBLISHERS_PER_KEY, - own_id: bytes | None = None): + own_id: bytes | None = None, seniority: float = 0.0): """Store of at most max_records records, loaded from state_path if it exists. - max_per_publisher defaults to max(MIN_PER_PUBLISHER, max_records // PUBLISHER_SHARE). own_id's - records (the node recording itself) aren't limited by it: they are as many as the node provides. + max_per_publisher defaults to max(MIN_PER_PUBLISHER, max_records // PUBLISHER_SHARE); a publisher + seen for less than `seniority` seconds (the node passes provider_seniority; 0: no ramp-up) may hold + newcomer_per_publisher, a tenth of it. own_id's records (the node recording itself) aren't limited by + either, and are never refused for room: they are as many as the node provides. """ self.max_records = max_records self.max_per_publisher = (max_per_publisher if max_per_publisher is not None else max(MIN_PER_PUBLISHER, max_records // PUBLISHER_SHARE)) + self.newcomer_per_publisher = min(self.max_per_publisher, + max(MIN_PER_PUBLISHER, self.max_per_publisher // NEWCOMER_SHARE)) + self.pool_size = max(0, max_records) // POOL_SHARE + self.main_size = max(0, max_records - self.pool_size) self.max_per_key = max_per_key self.own_id = own_id + self.seniority = seniority self.state_path = state_path self.ttl = ttl self.clock = clock self.rng = rng self._records: dict[bytes, dict[bytes, _Record]] = {} # key -> publisher -> record - # Insertion order is first-seen order, so the publisher seen most recently is the last one self._publishers: dict[bytes, _Publisher] = {} + self._order = itertools.count() # first-seen tie-breaker + # Publishers with records outside the pool, newest first: (-first_seen, -order, publisher). Entries go + # stale as publishers lose their last such record and are dropped when they reach the top + self._newest: list[tuple[float, int, bytes]] = [] + self._pool: list[tuple[bytes, bytes]] = [] # pooled (key, publisher) records, for a random pick + self._pool_index: dict[tuple[bytes, bytes], int] = {} # position in _pool + # Publishers whose last record went: publisher -> (first seen, when it went), oldest departure first + self._departed: OrderedDict[bytes, tuple[float, float]] = OrderedDict() + self._departed_size = max(MIN_PER_PUBLISHER, max(0, max_records) // DEPARTED_SHARE) self._count = 0 + self._main_count = 0 self._dirty = False self._last_expire = float("-inf") self._lock = threading.RLock() @@ -101,12 +132,13 @@ def __init__(self, max_records: int, state_path: str | None, ttl: float = PROVID def store(self, key: bytes, publisher: bytes) -> bool: """Record that publisher provides key, or refresh its record. False if refused. - Refused if the publisher already holds max_per_publisher records (expired records don't count; - own_id is exempt). A key with max_per_key publishers evicts one at random, never one of the K/2 - oldest records (those /find replies list first). Then a full store evicts a record of the - publisher first seen most recently, unless that is the newcomer itself (or the newcomer is a new - publisher, hence the newest): then it is refused, so new identities can neither displace older - publishers nor churn their own records. + Refused if the publisher already holds its allowance (max_per_publisher, or newcomer_per_publisher + while it has been seen for less than `seniority`; expired records don't count; own_id is exempt). A + key with max_per_key publishers evicts one at random, never one of the K/2 oldest records (those + /find replies list first). Then, if the store outside the pool is full, a publisher seen before the + newest one there evicts a record of that newest publisher; any other goes into the pool, evicting a + random pooled record if it is full (refused if that record is its own, so it can't churn its own + records). So new identities can't displace older publishers, and a new publisher always gets in. """ check_id(key) check_id(publisher) @@ -115,6 +147,8 @@ def store(self, key: bytes, publisher: bytes) -> bool: record = self._live_record(key, publisher, now) if record is not None: record.last_stored = now + if record.pooled and self._main_count < self.main_size: + self._unpool(key, publisher, record) # room outside the pool: it leaves random eviction self._dirty = True return True # An expired record still counted is replaced, starting a new first-stored time @@ -124,16 +158,24 @@ def store(self, key: bytes, publisher: bytes) -> bool: if self._count >= self.max_records and now - self._last_expire >= _EXPIRE_INTERVAL: self._expire(now) - if self._count >= self.max_records: - if not self._evict_for(publisher): - return False + pooled = self._make_room(publisher, now) + if pooled is None: + return False entry = self._publishers.get(publisher) if entry is None: - entry = self._publishers[publisher] = _Publisher(now) + first_seen = self._departed_first_seen(publisher, now) + self._departed.pop(publisher, None) + entry = self._publishers[publisher] = _Publisher(now if first_seen is None else first_seen, + next(self._order)) entry.keys.add(key) - self._records.setdefault(key, {})[publisher] = _Record(now, now) + record = self._records.setdefault(key, {})[publisher] = _Record(now, now, pooled) self._count += 1 + if pooled: + self._pool_index[(key, publisher)] = len(self._pool) + self._pool.append((key, publisher)) + else: + self._add_main(publisher, entry) self._dirty = True return True @@ -158,6 +200,21 @@ def provide(self, publisher: bytes, keys: Iterable[bytes], withdrawn: Iterable[b self.withdraw(key, publisher) return sum(self.store(key, publisher) for key in keys) + def allowance(self, publisher: bytes) -> int | None: + """How many records publisher may hold now: None (no limit) for own_id.""" + with self._lock: + return self._allowance(publisher, self.clock()) + + def _allowance(self, publisher: bytes, now: float) -> int | None: + """allowance() with the lock held.""" + if publisher == self.own_id: + return None + entry = self._publishers.get(publisher) + first_seen = entry.first_seen if entry is not None else self._departed_first_seen(publisher, now) + if first_seen is not None and now - first_seen >= self.seniority: + return self.max_per_publisher + return self.newcomer_per_publisher + def _within_limits(self, key: bytes, publisher: bytes, now: float) -> bool: """True if publisher may add a record for key under the per-publisher limit; makes room under the per-key limit by evicting a random non-senior publisher of key. @@ -165,9 +222,10 @@ def _within_limits(self, key: bytes, publisher: bytes, now: float) -> bool: Expired records are dropped first where a limit is reached, so they don't count. Caller holds the lock. """ entry = self._publishers.get(publisher) - if entry is not None and len(entry.keys) >= self.max_per_publisher and publisher != self.own_id: + allowance = self._allowance(publisher, now) + if entry is not None and allowance is not None and len(entry.keys) >= allowance: self._drop_expired([(owned, publisher) for owned in entry.keys], now) - if publisher in self._publishers and len(self._publishers[publisher].keys) >= self.max_per_publisher: + if publisher in self._publishers and len(self._publishers[publisher].keys) >= allowance: return False records = self._records.get(key) if records is not None and len(records) >= self.max_per_key: @@ -189,31 +247,134 @@ def _drop_expired(self, pairs: list[tuple[bytes, bytes]], now: float): self._remove(key, publisher) self._dirty = True - def _evict_for(self, publisher: bytes) -> bool: - """Make room for a record of publisher by evicting one of the newest publisher's. False if refused.""" - newest = next(reversed(self._publishers), None) - if newest is None or newest == publisher or publisher not in self._publishers: + def _make_room(self, publisher: bytes, now: float) -> bool | None: + """Where a new record of publisher goes: False outside the pool, True in it, None if refused. Evicts + what it must. Caller holds the lock.""" + if self._main_count < self.main_size: return False - # Of the newest publisher's records, drop the one it refreshed least recently - entry = self._publishers[newest] - victim = min(entry.keys, key=lambda key: self._records[key][newest].last_stored) - self._remove(victim, newest) + own = publisher == self.own_id + entry = self._publishers.get(publisher) + if entry is not None: + rank = self._rank(entry) + else: + # Back within the grace: as old as when first seen, and older than anyone first seen at that time + first_seen = self._departed_first_seen(publisher, now) + rank = None if first_seen is None else (first_seen, -1) + newest = self._newest_main(skip={publisher, self.own_id}) if own or rank is not None else None + if newest is not None and (own or rank < self._rank(newest[1])): + # Seen before the newest publisher outside the pool: that one loses the record it refreshed least + # recently (own records always get in this way) + victim_id, victim = newest + key = min((k for k in victim.keys if not self._records[k][victim_id].pooled), + key=lambda k: self._records[k][victim_id].last_stored) + self._remove(key, victim_id) + return False + if self.pool_size <= 0: + return None + if len(self._pool) < self.pool_size: + return True + key, victim_id = self._pool[self.rng.randrange(len(self._pool))] + if victim_id == publisher and not own: + return None # a publisher can't churn its own pooled records + self._remove(key, victim_id) return True + @staticmethod + def _rank(entry: _Publisher) -> tuple[float, int]: + """Sort key for first-seen order: lower was seen earlier.""" + return entry.first_seen, entry.order + + def _newest_main(self, skip: set) -> tuple[bytes, _Publisher] | None: + """The publisher first seen most recently that has a record outside the pool, leaving out skip; None if + there is none. Caller holds the lock.""" + aside = [] + found = None + while self._newest: + neg_seen, neg_order, publisher = self._newest[0] + entry = self._publishers.get(publisher) + if entry is None or entry.main <= 0 or (-neg_seen, -neg_order) != self._rank(entry): + heapq.heappop(self._newest) # stale: gone, pooled only, or re-added since + continue + if publisher in skip: + aside.append(heapq.heappop(self._newest)) + continue + found = publisher, entry + break + for item in aside: + heapq.heappush(self._newest, item) + return found + + def _add_main(self, publisher: bytes, entry: _Publisher): + """Count a record of publisher outside the pool. Caller holds the lock.""" + self._main_count += 1 + entry.main += 1 + if entry.main == 1: + heapq.heappush(self._newest, (-entry.first_seen, -entry.order, publisher)) + if len(self._newest) > 2 * len(self._publishers) + 64: + self._rebuild_newest() + + def _rebuild_newest(self): + """Recreate the newest-publisher heap without stale entries. Caller holds the lock.""" + self._newest = [(-entry.first_seen, -entry.order, publisher) + for publisher, entry in self._publishers.items() if entry.main > 0] + heapq.heapify(self._newest) + + def _unpool(self, key: bytes, publisher: bytes, record: _Record): + """Move a pooled record out of the pool. Caller holds the lock.""" + self._drop_from_pool(key, publisher) + record.pooled = False + self._add_main(publisher, self._publishers[publisher]) + + def _drop_from_pool(self, key: bytes, publisher: bytes): + """Forget a pooled record's place in the pool, swapping the last one in. Caller holds the lock.""" + index = self._pool_index.pop((key, publisher)) + last = self._pool.pop() + if index < len(self._pool): + self._pool[index] = last + self._pool_index[last] = index + def _remove(self, key: bytes, publisher: bytes) -> bool: """Delete one record, and the publisher once it has none left. Caller holds the lock.""" records = self._records.get(key) - if records is None or records.pop(publisher, None) is None: + if records is None: + return False + record = records.pop(publisher, None) + if record is None: return False if not records: del self._records[key] self._count -= 1 entry = self._publishers[publisher] entry.keys.discard(key) + if record.pooled: + self._drop_from_pool(key, publisher) + else: + self._main_count -= 1 + entry.main -= 1 if not entry.keys: - del self._publishers[publisher] # spec: first-seen times are dropped once no records are left + del self._publishers[publisher] + self._depart(publisher, entry.first_seen, self.clock()) return True + def _depart(self, publisher: bytes, first_seen: float, now: float): + """Keep the first-seen time of a publisher whose last record went, for a TTL, if it had been seen for + `seniority` (a younger one has no standing to lose, and leaving those out means a flood of fresh + identities can't push the others out). Caller holds the lock.""" + self._departed.pop(publisher, None) + if now - first_seen < self.seniority: + return + self._departed[publisher] = (first_seen, now) + while len(self._departed) > self._departed_size: + self._departed.popitem(last=False) + + def _departed_first_seen(self, publisher: bytes, now: float) -> float | None: + """First-seen time of a publisher whose last record went less than a TTL ago, else None. Caller holds + the lock.""" + departed = self._departed.get(publisher) + if departed is None or now - departed[1] >= self.ttl: + return None + return departed[0] + def _live_record(self, key: bytes, publisher: bytes, now: float) -> _Record | None: """The unexpired record of publisher for key, or None. Caller holds the lock.""" record = self._records.get(key, {}).get(publisher) @@ -236,6 +397,10 @@ def _expire(self, now: float) -> int: for publisher, record in records.items() if now - record.last_stored >= self.ttl] for key, publisher in expired: self._remove(key, publisher) + # Departures are in order, oldest first + while self._departed and now - next(iter(self._departed.values()))[1] >= self.ttl: + self._departed.popitem(last=False) + self._dirty = True self._dirty = self._dirty or bool(expired) return len(expired) @@ -249,10 +414,18 @@ def _clamp(self, now: float): if record.first_stored > now: record.first_stored = now self._dirty = True + moved = False for entry in self._publishers.values(): if entry.first_seen > now: entry.first_seen = now + moved = True + for publisher, (first_seen, departed) in list(self._departed.items()): + if first_seen > now or departed > now: + self._departed[publisher] = (min(first_seen, now), min(departed, now)) self._dirty = True + if moved: + self._rebuild_newest() + self._dirty = True # Queries @@ -336,8 +509,11 @@ def save(self): "version": _STATE_VERSION, "publishers": {publisher.hex(): entry.first_seen for publisher, entry in self._publishers.items()}, - "records": [[key.hex(), publisher.hex(), record.first_stored, record.last_stored] + "records": [[key.hex(), publisher.hex(), record.first_stored, record.last_stored, + int(record.pooled)] for key, records in self._records.items() for publisher, record in records.items()], + "departed": [[publisher.hex(), first_seen, departed] + for publisher, (first_seen, departed) in self._departed.items()], } self._dirty = False directory = os.path.dirname(os.path.abspath(self.state_path)) @@ -350,36 +526,59 @@ def save(self): raise def _load(self): - """Load state_path if it exists. A missing or unreadable file starts empty: records get republished.""" + """Load state_path if it exists. A missing or unreadable file starts empty: records get republished. + + Files written before the newcomer pool (four fields per record, no departures) load with every record + outside the pool, as far as it has room. + """ try: with open(self.state_path, encoding="utf-8") as f: state = json.load(f) if state.get("version") != _STATE_VERSION: return first_seen = {bytes.fromhex(p): float(t) for p, t in state["publishers"].items()} - records = [(bytes.fromhex(k), bytes.fromhex(p), float(first), float(last)) - for k, p, first, last in state["records"]] - except (OSError, ValueError, KeyError, TypeError, AttributeError, OverflowError): + records = [(bytes.fromhex(r[0]), bytes.fromhex(r[1]), float(r[2]), float(r[3]), + len(r) > 4 and bool(r[4])) for r in state["records"]] + departed = [(bytes.fromhex(p), float(seen), float(left)) for p, seen, left in state.get("departed", [])] + except (OSError, ValueError, KeyError, TypeError, AttributeError, OverflowError, IndexError): return now = self.clock() # Times in the future (a clock that ran ahead, since corrected) count as now; NaN is dropped first_seen = {p: min(t, now) for p, t in first_seen.items() if t == t} - records = [(k, p, min(first, now), min(last, now)) for k, p, first, last in records + records = [(k, p, min(first, now), min(last, now), pooled) for k, p, first, last, pooled in records if first == first and last == last] # Rebuild first-seen order, oldest publisher first for publisher, seen in sorted(first_seen.items(), key=lambda item: item[1]): if len(publisher) == ID_LENGTH: - self._publishers[publisher] = _Publisher(seen) + self._publishers[publisher] = _Publisher(seen, next(self._order)) # Oldest records first, so each key's records are in first-stored order and the limits keep the oldest - for key, publisher, first, last in sorted(records, key=lambda record: record[2]): + for key, publisher, first, last, pooled in sorted(records, key=lambda record: record[2]): if (len(key) != ID_LENGTH or publisher not in self._publishers or now - last >= self.ttl - or self._count >= self.max_records or publisher in self._records.get(key, {}) + or publisher in self._records.get(key, {}) or (publisher != self.own_id and len(self._publishers[publisher].keys) >= self.max_per_publisher) or len(self._records.get(key, ())) >= self.max_per_key): continue - self._records.setdefault(key, {})[publisher] = _Record(first, last) - self._publishers[publisher].keys.add(key) + # Where it was if there is room there, else wherever there is + main_room = self._main_count < self.main_size + pool_room = len(self._pool) < self.pool_size + if not (main_room or pool_room): + continue + pooled = pool_room if pooled or not main_room else False + entry = self._publishers[publisher] + self._records.setdefault(key, {})[publisher] = _Record(first, last, pooled) + entry.keys.add(key) self._count += 1 + if pooled: + self._pool_index[(key, publisher)] = len(self._pool) + self._pool.append((key, publisher)) + else: + self._main_count += 1 + entry.main += 1 for publisher in [p for p, entry in self._publishers.items() if not entry.keys]: del self._publishers[publisher] + self._rebuild_newest() + for publisher, seen, left in sorted(departed, key=lambda item: item[2]): + if (len(publisher) == ID_LENGTH and seen == seen and left == left and publisher not in self._publishers + and now - min(left, now) < self.ttl): + self._depart(publisher, min(seen, now), min(left, now)) diff --git a/rnfs/network/protocol.py b/rnfs/network/protocol.py index 4d9be40..0622484 100644 --- a/rnfs/network/protocol.py +++ b/rnfs/network/protocol.py @@ -18,8 +18,8 @@ import RNS from RNS.vendor import umsgpack -from rnfs.constants import (BLOCKS_PATH, FIND_PATH, ID_LENGTH, K, KIB, MAX_BLOCK_SIZE, MAX_REQUEST_SIZE, - MAX_RESPONSE_SIZE, PIN_PATH, PROVIDE_PATH) +from rnfs.constants import (BLOCKS_PATH, FIND_PATH, ID_LENGTH, K, KIB, MAX_BLOCK_SIZE, MAX_PROVIDE_KEYS, + MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE, PIN_PATH, PROVIDE_PATH) from rnfs.content.cid import Cid __all__ = ["FIND_PATH", "PROVIDE_PATH", "BLOCKS_PATH", "PIN_PATH"] @@ -114,10 +114,12 @@ def encode_provide(keys: list[bytes], withdrawn: list[bytes]) -> list: def parse_provide_request(data) -> tuple[list[bytes], list[bytes]] | None: - """(keys, withdrawn keys) from a /provide request, or None if malformed.""" + """(keys, withdrawn keys) from a /provide request, or None if malformed (over MAX_PROVIDE_KEYS in all).""" if type(data) is not list or len(data) != 2: return None keys, withdrawn = data + if type(keys) is not list or type(withdrawn) is not list or len(keys) + len(withdrawn) > MAX_PROVIDE_KEYS: + return None if not is_id_list(keys) or not is_id_list(withdrawn): return None return keys, withdrawn diff --git a/rnfs/node.py b/rnfs/node.py index 6b5988c..81f8c3d 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -274,7 +274,7 @@ def _setup(self, config: Config, node_id: bytes, client, slow: bool, names_netwo self.routing = RoutingTable(node_id, config, admit=self._is_member_node if config.private else None) self.records = ProviderStore(config.max_records, os.path.join(config.state_dir, PROVIDERS_FILE), - own_id=node_id) + own_id=node_id, seniority=config.provider_seniority) self.storage = Storage(config.blocks_dir, config.state_dir, config.max_storage) self.shared_folder = SharedFolder(config.files_dir, self.storage, config.state_dir) self.pin_names = PinNames(config.state_dir) # where pins came from, for pin ls (local only) diff --git a/specification.md b/specification.md index 1d4fb75..1dfca7c 100644 --- a/specification.md +++ b/specification.md @@ -123,7 +123,8 @@ pass a `max_response_size` just above their largest honest answer. left unanswered, except a request over `MAX_REQUEST_SIZE`, which RNS drops unanswered. - Every request fits in one packet: at most `min(Link.get_mdu(), MAX_REQUEST_SIZE)` bytes including RNS's request framing (~28 bytes). Batch sizes are computed from that (e.g. 22 keys per `/provide` at the default - MTU, counting both lists). + MTU, counting both lists). A `/provide` carries at most 55 keys, counting both lists (the most that fit + `MAX_REQUEST_SIZE`); a longer one is malformed. **Limits.** These keep a node usable under load. - Requests are at most `MAX_REQUEST_SIZE` (`Destination.set_max_request_size`). @@ -473,9 +474,19 @@ and repeat. - A record expires `PROVIDER_TTL` after it was last stored, keeps its first-stored time across republishes, and is removed on withdrawal. These times are wall-clock and persisted; one later than the current time (a clock since corrected backwards) counts as now, so a record can't outlive its TTL. -- A node keeps at most `max_records`. When full, it evicts a record of the publisher it first saw most - recently, so a flood of new identities displaces only itself. First-seen times are persisted, and dropped - once a publisher has no records left. +- A node keeps at most `max_records`, a tenth of them in a *newcomer pool*. When the rest is full, a + publisher first seen before the most recent one there evicts a record of that most recent publisher (the + one it stored least recently), so a flood of new identities can't displace older publishers. Any other + publisher (one not seen before, or the most recent one) stores into the pool, evicting a pooled record + chosen at random when the pool is full, but never one of its own: so a new publisher can always store + something, and a flood churns only the pool. A pooled record leaves the pool when it is republished while + the rest has room. The node's own records are always accepted. +- First-seen times are persisted, and kept for `PROVIDER_TTL` after a publisher's last record is gone + (rnfs: for publishers seen for at least `provider_seniority`, at most `max_records / 10` of them, the + oldest departure forgotten first), so a publisher that was away for a while isn't new again. +- A node SHOULD limit how many records one publisher holds, its own excepted (rnfs: `max_records / 100`, at + least 10, once the publisher has been seen for `provider_seniority`, and a tenth of that, at least 10, + before), so one identity can't fill the store and a fresh one gets little. - Providers republish every `republish_interval`, which MUST be less than `PROVIDER_TTL / 2`. Providers also record themselves locally. Keys that aren't CIDs' (kept names, section 9) are republished every max(`republish_interval`, 3 days), each with a fixed jitter of ±10% derived from the key, so many keys @@ -983,7 +994,7 @@ digits). A node MUST NOT start with an invalid setting. | `announce_interval` | 6 h | SHOULD NOT be under 1 h (RNS's per-destination announce rate limit) | | `startup_announce_delay` | 30 s | Second announce after startup | | `max_parallel_providers` | 3; 1 on slow nodes | | -| `provider_seniority` | 1 day | Providers in the routing table this long are tried first | +| `provider_seniority` | 1 day | Providers (and keepers) in the routing table this long are tried first; a publisher seen this long gets its full share of the provider store | | `response_target` | 60 s | Transfer time a `/blocks` response aims for | | `deadline_slack` | 30 s | Added to response deadlines; minimum age before a response can be displaced | | `stall_timeout` | 120 s | | diff --git a/tests/security/test_m4_provider_admission.py b/tests/security/test_m4_provider_admission.py index a5b4797..d48ef2c 100644 --- a/tests/security/test_m4_provider_admission.py +++ b/tests/security/test_m4_provider_admission.py @@ -1,16 +1,20 @@ -"""M4: a provider store filled by about 100 publishers refuses every publisher it hasn't seen yet, this node included. +"""M4: a provider store filled by about 100 publishers refused every publisher it hadn't seen yet, this node included. A ProviderStore of the default size (Config().max_records) is filled to the brim by 100 identities storing their per-publisher maximum each. A publisher that stored before the flood is still accepted; a new one, and the node's -own ID with no records yet, are refused. Passing tests pin down today's limits (the per-publisher cap, and how many -keys one /provide request may carry on the server side), which the fix may change. +own ID with no records yet, must be too (the newcomer pool, and own records always admitted). Further tests pin +down the limits: the per-publisher cap and its ramp-up with age, the first-seen grace after a publisher's last +record goes, and how many keys one /provide request may carry on the server side. """ +import os +import random +import tempfile import unittest import RNS from rnfs.config import Config -from rnfs.constants import ID_LENGTH, MAX_REQUEST_SIZE, PROVIDE_PATH +from rnfs.constants import DAY, ID_LENGTH, MAX_PROVIDE_KEYS, MAX_REQUEST_SIZE, PROVIDE_PATH, PROVIDER_TTL from rnfs.dht.providers import ProviderStore from rnfs.network import protocol, server from rnfs.network.identity import node_id_for_identity @@ -55,18 +59,132 @@ def test_established_publisher_accepted(self): self.assertTrue(self.store.store(ident(2), HONEST_OLD)) self.assertEqual(self.store.record_count(), self.store.max_records) - # M4: fails until a full store keeps room for newcomers (a newcomer pool, or an allowance that grows with age) - @unittest.expectedFailure def test_new_publisher_can_store(self): accepted = sum(self.store.store(ident(10 + i), NEWCOMER) for i in range(10)) self.assertGreater(accepted, 0, "a new honest publisher stored none of 10 records in the full store") - # M4: fails until the node's own records are admitted to its full store whatever its first-seen time - @unittest.expectedFailure def test_own_records_always_accepted(self): self.assertIsNone(self.store.first_seen(OWN)) self.assertTrue(self.store.store(ident(3), OWN), "the node's own record was refused by its full store") + def test_own_records_never_refused(self): + """However many: each evicts a record of the newest publisher outside the pool, never the honest one.""" + self.assertEqual(sum(self.store.store(ident(10_000 + i), OWN) for i in range(2000)), 2000) + self.assertEqual(self.store.record_count(), self.store.max_records) + self.assertEqual(self.store.providers(ident(1)), [HONEST_OLD]) + + def test_newcomers_only_churn_the_pool(self): + """A flood of new identities displaces pooled records only: the publishers seen first keep everything.""" + self.assertEqual(sum(self.store.store(ident(10_000 + i), ident((0xE << 120) + i)) for i in range(2000)), 2000) + self.assertEqual(self.store.record_count(), self.store.max_records) + self.assertEqual(self.store.providers(ident(1)), [HONEST_OLD]) + first_flooder = ident(0xF << 120) + held = sum(first_flooder in self.store.providers(ident((1 << 64) + 1 + i)) for i in range(1000)) + self.assertEqual(held, 1000) + + +class AllowanceTest(unittest.TestCase): + """The per-publisher allowance grows with age: a tenth until a publisher was seen provider_seniority ago.""" + + def setUp(self): + """A default-sized store ramping up over a day, as the node makes it.""" + self.clock = FakeClock(1_700_000_000.0) + self.store = ProviderStore(Config().max_records, None, clock=self.clock, own_id=OWN, + seniority=Config().provider_seniority) + self.assertEqual(Config().provider_seniority, DAY) + + def test_new_publisher_gets_a_tenth(self): + self.assertEqual((self.store.max_per_publisher, self.store.newcomer_per_publisher), (1000, 100)) + publisher = ident(7) + self.assertEqual(self.store.allowance(publisher), 100) + self.assertEqual(sum(self.store.store(ident(100 + i), publisher) for i in range(1000)), 100) + self.clock.now += DAY + self.assertEqual(self.store.allowance(publisher), 1000) + self.assertEqual(sum(self.store.store(ident(100 + i), publisher) for i in range(1001)), 1000) + + def test_own_id_unlimited_from_the_start(self): + self.assertIsNone(self.store.allowance(OWN)) + self.assertEqual(sum(self.store.store(ident(100 + i), OWN) for i in range(1500)), 1500) + + def test_first_seen_kept_for_a_ttl_after_the_last_record_goes(self): + publisher = ident(7) + self.assertTrue(self.store.store(ident(100), publisher)) + first_seen = self.store.first_seen(publisher) + self.clock.now += 2 * DAY + self.store.withdraw(ident(100), publisher) + self.assertIsNone(self.store.first_seen(publisher)) + # Back within a TTL: its age, and so its full allowance, comes back with it + self.clock.now += PROVIDER_TTL - 1 + self.assertEqual(self.store.allowance(publisher), 1000) + self.assertTrue(self.store.store(ident(101), publisher)) + self.assertEqual(self.store.first_seen(publisher), first_seen) + # Records expiring count as leaving too; after a TTL more it is new again + self.clock.now += PROVIDER_TTL + self.store.expire() + self.clock.now += PROVIDER_TTL + self.store.expire() + self.assertEqual(self.store.allowance(publisher), 100) + self.assertTrue(self.store.store(ident(102), publisher)) + self.assertEqual(self.store.first_seen(publisher), self.clock.now) + + def test_young_publishers_are_not_remembered(self): + """A publisher seen for less than provider_seniority has no standing to keep, so a flood of fresh + identities can't push older publishers' first-seen times out of the bounded memory.""" + self.assertTrue(self.store.store(ident(100), ident(7))) + self.clock.now += 60 + self.store.withdraw(ident(100), ident(7)) + self.clock.now += 60 + self.assertTrue(self.store.store(ident(100), ident(7))) + self.assertEqual(self.store.first_seen(ident(7)), self.clock.now) + + +class ReturningPublisherTest(unittest.TestCase): + """A publisher back within the grace counts as seen when it was first seen, also for eviction.""" + + def test_returning_publisher_evicts_the_newest(self): + clock = FakeClock(1_700_000_000.0) + store = ProviderStore(100, None, clock=clock, seniority=DAY) + old, newer = ident(1), ident(2) + store.store(ident(100), old) + clock.now += 2 * DAY + store.withdraw(ident(100), old) # away, with its first-seen time remembered + for i in range(store.max_per_publisher): + store.store(ident(200 + i), newer) + for n in range(8): # fill the store outside the pool with others seen after it + for i in range(store.max_per_publisher): + store.store(ident(1000 * (n + 1) + i), ident(10 + n)) + clock.now += 1 + self.assertEqual(store.record_count(), store.main_size) + # Seen first, so it displaces the newest publisher outside the pool rather than going into the pool + self.assertTrue(store.store(ident(100), old)) + self.assertEqual(store.record_count(), store.main_size) + self.assertEqual(store.providers(ident(100)), [old]) + + +class PersistenceTest(unittest.TestCase): + """The pool and remembered first-seen times survive a restart.""" + + def test_round_trip(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "providers.json") + clock = FakeClock(1_700_000_000.0) + store = ProviderStore(100, path, clock=clock, rng=random.Random(1), seniority=DAY) + gone = ident(9) + store.store(ident(1), gone) + clock.now += 2 * DAY + store.withdraw(ident(1), gone) + for n in range(10): # 90 outside the pool, then the last 10 pooled + for i in range(10): + store.store(ident(100 * (n + 1) + i), ident(10 + n)) + store.save() + loaded = ProviderStore(100, path, clock=clock, rng=random.Random(1), seniority=DAY) + self.assertEqual(loaded.record_count(), 100) + self.assertEqual(loaded.allowance(gone), loaded.max_per_publisher) + # Pooled records stay pooled: a newcomer evicts one of them, not a record outside the pool + self.assertTrue(loaded.store(ident(5), ident(99))) + self.assertEqual(sum(len(loaded.providers(ident(100 * (n + 1) + i))) for n in range(9) for i in range(10)), + 90) + class PerPublisherCapTest(unittest.TestCase): """Today's per-publisher cap: max(10, max_records // 100).""" @@ -86,13 +204,18 @@ class ProvideRequestSizeTest(unittest.TestCase): """How many keys one /provide may carry: only RNS's request size limit bounds it, not the parser.""" def test_keys_per_request(self): - """Today 55 keys fit a 1 KiB request; the server takes them all in one /provide. rnfs's client sends 22.""" + """55 keys fit a 1 KiB request; the server takes them all in one /provide. rnfs's client sends 22.""" keys = [ident(1000 + i) for i in range(1000)] most = max(n for n in range(1, 100) if protocol.request_size(PROVIDE_PATH, protocol.encode_provide(keys[:n], [])) <= MAX_REQUEST_SIZE) self.assertEqual(most, 55) - # The parser itself has no count limit - self.assertEqual(protocol.parse_provide_request(protocol.encode_provide(keys, [])), (keys, [])) + # The parser itself has a count limit now (M4 fix; it used to take any number): MAX_PROVIDE_KEYS in all, + # which is what fits MAX_REQUEST_SIZE + self.assertEqual(MAX_PROVIDE_KEYS, most) + self.assertIsNone(protocol.parse_provide_request(protocol.encode_provide(keys, []))) + self.assertIsNone(protocol.parse_provide_request(protocol.encode_provide(keys[:30], keys[30:56]))) + self.assertEqual(protocol.parse_provide_request(protocol.encode_provide(keys[:30], keys[30:55])), + (keys[:30], keys[30:55])) # The request server lets RNS accept up to MAX_REQUEST_SIZE and records every key of such a request backend = FakeBackend() destination = FakeDestination() @@ -106,8 +229,15 @@ def test_keys_per_request(self): self.assertIs(handler(PROVIDE_PATH, protocol.encode_provide(keys[:most], []), bytes(16), link.link_id, identity, 0.0), True) self.assertEqual(backend.provided, [(node_id_for_identity(identity), keys[:most], [])]) - # rnfs's own client batches to one packet at the default link MDU + # rnfs's own client batches to one packet at the default link MDU, and never sends more than the server takes self.assertEqual(len(protocol.provide_batches(keys, [], None)[0][0]), 22) + batches = protocol.provide_batches(keys[:600], keys[600:], 1_000_000) + self.assertEqual(max(len(stored) + len(withdrawn) for stored, withdrawn in batches), MAX_PROVIDE_KEYS) + # One key more than that is refused as malformed, recording nothing + backend.provided.clear() + self.assertIs(handler(PROVIDE_PATH, protocol.encode_provide(keys[:most + 1], []), bytes(16), link.link_id, + identity, 0.0), False) + self.assertEqual(backend.provided, []) if __name__ == "__main__": From 6b23f35f5ef8754c815d9beaebec43523852f0a5 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:34:05 +1030 Subject: [PATCH 015/107] Show only the operator's pins and allowlisted CIDs on the display-mu-file page --- .../display-mu-file/README.md | 103 +++++ .../display-mu-file/index.mu | 281 +++++++++++-- tests/security/_pages_helpers.py | 82 +++- tests/security/test_h2_display_page_pins.py | 397 ++++++++++++++---- .../security/test_l11_display_page_micron.py | 102 +++++ tests/security/test_l14_page_local_paths.py | 52 ++- .../test_n1_display_page_unbounded.py | 45 +- 7 files changed, 908 insertions(+), 154 deletions(-) create mode 100644 companion-nomadnet-sites/display-mu-file/README.md create mode 100644 tests/security/test_l11_display_page_micron.py diff --git a/companion-nomadnet-sites/display-mu-file/README.md b/companion-nomadnet-sites/display-mu-file/README.md new file mode 100644 index 0000000..202eba5 --- /dev/null +++ b/companion-nomadnet-sites/display-mu-file/README.md @@ -0,0 +1,103 @@ +# display-mu-file + +A NomadNet page that shows an RNFS file by its CID, for example a Micron page you published with RNFS. +Visitors open it with the CID as a link variable: + +``` +`[My page`:/page/display-mu-file.mu`cid=bafkreiailvfecuuvt6tctx3owax2jic7y5af2ndriwo6xymcq2uwyut34a] +``` + +## Install + +Copy `index.mu` into NomadNet's pages directory (usually `~/.nomadnetwork/storage/pages/`), for example as +`display-mu-file.mu`, and make it executable (`chmod +x`). The page runs the `rnfs` CLI, so the rnfs daemon +must be running as the same user as NomadNet. + +## What visitors can see + +Anyone who can reach your NomadNet node can open the page. The page never fetches, pins or provides anything +just because a visitor asked for it. It shows a file only if one of these is true: + +- **You pinned it and this node has all of it.** The pin must come from you (source `user` or `folder` in + `rnfs pin ls`), not from a contact, a followed name or a remote pin request. The page checks this with + `rnfs pin ls`, which doesn't touch the network. For any other CID it runs nothing else, so it doesn't even + fetch the root block. +- **Its CID is in your allowlist** (see below). These are the only files the page fetches from the network + if this node doesn't have them yet. + +Files your pins share are shown to anyone who knows the CID, whatever `private` mode allows over RNFS +itself. Don't pin files there that you wouldn't put on the page. + +The page also refuses: + +- files larger than `MAX_SIZE` (200 KB), according to `rnfs stat`; +- folders; +- sealed (encrypted) files: it reads files with `rnfs cat --raw`, so it never decrypts a file sealed to + this node, and it refuses one it recognises; +- files that aren't UTF-8 text. + +### How files are rendered + +- **Allowlisted CIDs** are printed as Micron, exactly as written: links, fields and formatting work. +- **Everything else**, including your own pins that aren't on the allowlist, is shown as plain text under a + "Third-party content" banner. The text is put in a Micron literal block, so its links, fields, headings + and page headers (such as `#!bg=`) don't work, and control characters are replaced. This stops someone + else's page (for example a phishing form, or one that imitates your own pages) from appearing as your + node's content. + +To show one of your own Micron pages as a page, add its CID to the allowlist. + +## The data directory + +The page keeps its files in `~/.local/state/rnfs-pages/display-mu-file/`, which is outside NomadNet's pages +directory, so none of it is served. The page creates the directory with mode 0700 on first use. + +| File | What it is | +| --- | --- | +| `allowlist.txt` | Your allowlist. You create and edit it. | +| `page.log` | Errors, with details. Moved to `page.log.1` past 1 MB. | +| `lock` | Lets only one request run at a time. | +| `rate.json` | Times of recent requests, for the rate limit. | + +NomadNet runs pages with a minimal environment. `RNFS_PAGE_DIR` overrides the directory, but it only works +when you run the page by hand, for example to test it: + +``` +var_cid= RNFS_PAGE_DIR=/tmp/page-test ./index.mu +``` + +### The allowlist + +`allowlist.txt` lists one CID per line. Text after `#` is a comment, and blank lines and anything that isn't +a CID are ignored: + +``` +# My pages +bafkreiailvfecuuvt6tctx3owax2jic7y5af2ndriwo6xymcq2uwyut34a # front page +``` + +An allowlisted CID that this node doesn't have yet is fetched with `rnfs cat` when a visitor opens it. That +read covers at most `MAX_SIZE` bytes, even if the file understates its size, and it pins nothing and starts +no daemon job. If it takes longer than the page's deadline (`TIMEOUT`, 120 s for the whole request), the +command is killed and nothing is left running. The fetched blocks are cached like any other read. With the +default `provide_strategy = "roots"`, this node then provides the file while it stays cached. To keep an +allowlisted file available, pin it yourself with `rnfs pin add `. + +### Busy and rate limits + +Only one request runs at a time. A visitor who arrives while another request is running is asked to try +again in a moment. There are also at most `RATE_LIMIT` requests (30) in any `RATE_WINDOW` (60 s), counted +across all visitors. Requests with a malformed CID aren't counted, because they never reach rnfs. Both checks +fail closed: if the page can't use its data directory, it shows nothing. You can change these numbers at the +top of `index.mu`. + +### The log + +Visitors only see short messages such as "can't be shown right now". The details go to `page.log`: CLI +error messages (which can contain local paths), exceptions and timeouts. Check the log if the page +misbehaves. + +## Upgrading from an earlier version + +Earlier versions of this page pinned (source `user`) and provided every CID a visitor asked for, without +limit. Check `rnfs pin ls` for pins you didn't make, and remove them with `rnfs pin rm `. diff --git a/companion-nomadnet-sites/display-mu-file/index.mu b/companion-nomadnet-sites/display-mu-file/index.mu index 6cd60ad..2b1fd15 100755 --- a/companion-nomadnet-sites/display-mu-file/index.mu +++ b/companion-nomadnet-sites/display-mu-file/index.mu @@ -1,41 +1,121 @@ #!/usr/bin/env python3 -"""NomadNet page that shows an RNFS file: pins it on this node and prints its content (e.g. a Micron page). +"""NomadNet page that shows an RNFS file this node shares (e.g. a Micron page), by CID. Link to it with the CID as a link variable, which NomadNet passes in the environment as var_cid: `[Example`:/page/display-mu-file.mu`cid=bafkreiailvfecuuvt6tctx3owax2jic7y5af2ndriwo6xymcq2uwyut34a] +Anyone can open the page, so it never fetches, pins or provides anything because a visitor asked: +- It shows a file only if this node already has it completely and pinned by the operator (pin source "user" + or "folder", as `rnfs pin ls` lists it), or if its CID is in the operator's allowlist file. Only + allowlisted CIDs are fetched if missing, with `rnfs cat` (a bounded read that pins nothing and starts no + job), and only allowlisted files are rendered as Micron; everything else is shown as plain text under a + banner saying it is third-party content. +- Requests run one at a time (a lock file) and at most RATE_LIMIT per RATE_WINDOW seconds (a state file); + when either says no, the page asks the visitor to try again later. +- Visitors see short, generic errors; the details (CLI errors, exceptions) go to a log file. +The allowlist, lock, rate state and log live in the page's data dir, PAGE_DIR; see this page's README. `B333`!Find out more about 'Reticulum Network File System' at the project's Github:`!`b `B333`F08f`_`[https://github.com/afit21/rnfs`https://github.com/afit21/rnfs]`_`f`b """ +import json import os import re import shutil import subprocess +import time +import unicodedata -# Larger files aren't pinned or shown. +# Larger files aren't shown. MAX_SIZE = 200 * 1024 -# How long to wait for each rnfs command; a fetch over a slow link can take a while. +# How long one page request may wait for rnfs in all; fetching an allowlisted file over a slow link takes a while. TIMEOUT = 120 +# At most RATE_LIMIT page requests (for well-formed CIDs) in any RATE_WINDOW seconds, from all visitors together. +RATE_LIMIT = 30 +RATE_WINDOW = 60 + +# Pin sources that mean the operator chose to keep the file (not a contact, a follow or a remote pin). +OPERATOR_SOURCES = {"user", "folder"} + +# The page's own data dir, outside NomadNet's pages dir so none of it is served. NomadNet runs pages with a +# minimal environment, so RNFS_PAGE_DIR is only for running the page by hand (and tests). +PAGE_DIR = os.environ.get("RNFS_PAGE_DIR") or os.path.join( + os.path.expanduser("~"), ".local", "state", "rnfs-pages", "display-mu-file") +ALLOWLIST = os.path.join(PAGE_DIR, "allowlist.txt") # one CID per line; "#" starts a comment +LOG = os.path.join(PAGE_DIR, "page.log") +LOG_MAX = 1024 * 1024 # the log is moved to page.log.1 past this size + # NomadNet may run this without ~/.local/bin on PATH. RNFS = shutil.which("rnfs") or os.path.expanduser("~/.local/bin/rnfs") # A bare CIDv1 in base32 (rnfs's form), so nothing else (a path, a nick, an option) reaches rnfs. CID = re.compile(r"b[a-z2-7]{20,200}") +# How a sealed (encrypted) file starts (rnfs/content/seal.py); the page never shows one. +SEALED_MAGIC = b"rnfs-sealed\n" + +BANNER = """`!Third-party content`! +This file is shown as plain text: links, fields and formatting in it don't work here. It may not have been +written or checked by this node's operator, so don't trust instructions or requests in it. +-""" + + +def printable(text): + """text as NomadNet will parse it, on one line: composed (NFC), without the combining marks and emoji + modifiers NomadNet drops before parsing a page (so none can hide markup behind it), and with every other + non-printable character (newlines, controls, bidi overrides) as U+FFFD.""" + text = unicodedata.normalize("NFC", str(text)) + return "".join(char if char.isprintable() else "\ufffd" for char in text + if not unicodedata.category(char).startswith("M") and not "\U0001f3fb" <= char <= "\U0001f3ff") + def escape(text): - """Escape Micron's formatting characters so text shows literally.""" - return text.replace("\\", "\\\\").replace("`", "\\`") + """text made safe to print within a Micron line: shown literally, also at the start of a line. + It is printable() (one line, nothing NomadNet drops); backslashes and backticks are escaped; "#!" can't + form a page header (NomadNet looks for "#!bg=" and "#!fg=" anywhere); a leading ">", "<", "#" or "-" (a + heading, depth reset, comment or divider) is escaped, and a leading escaped backtick gets a space before it + (NomadNet reads "\\`t" and "\\`{" at the start of a line as a table or partial). + """ + text = printable(text).replace("\\", "\\\\").replace("`", "\\`").replace("#!", "#\\!") + if text[:1] in (">", "<", "#", "-"): + text = "\\" + text + elif text.startswith("\\`"): + text = " " + text + return text + + +def plain_text(text): + """Micron that shows text exactly as plain text: a literal block (`=), so no line is markup. + + Inside the block only a line that is exactly "`=" means something (it ends the block); it is escaped. + Each line is printable() (tabs as spaces first), so what NomadNet drops before parsing can't turn a line + into "`=", and "#!" is split with a word joiner so the content can't set the page's colours (NomadNet looks + for "#!bg=" and "#!fg=" anywhere in a page). + """ + lines = [] + for line in text.splitlines(): + line = printable(line.replace("\t", " ")) + line = line.replace("#!", "#\u2060!") + lines.append("\\`=" if line == "`=" else line) + return "\n".join(["`=", *lines, "`="]) -def rnfs(*args): - """Run an rnfs command; its result (returncode, stdout bytes, stderr text).""" - result = subprocess.run([RNFS, *args], capture_output=True, timeout=TIMEOUT) - return result.returncode, result.stdout, result.stderr.decode("utf-8", "replace").strip() + +def log(message): + """Append a line to the page's log (never shown to visitors); a log that can't be written is skipped.""" + try: + if os.path.getsize(LOG) > LOG_MAX: + os.replace(LOG, LOG + ".1") + except OSError: + pass + try: + with open(LOG, "a", encoding="utf-8") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message!r}\n") + except OSError: + pass def fail(message, micron=""): @@ -47,11 +127,99 @@ def fail(message, micron=""): raise SystemExit -def size_of(cid): +def unavailable(cid, detail): + """Log detail and tell the visitor, without it, that cid can't be shown right now; stop.""" + log(f"{cid}: {detail}") + fail(f"{cid} can't be shown right now; try again later.") + + +class Deadline: + """The time left for this request's rnfs commands.""" + + def __init__(self, seconds): + """Start counting seconds from now.""" + self.end = time.monotonic() + seconds + + def left(self): + """Seconds left (at least 1, so a command that is due anyway still gets a moment).""" + return max(1.0, self.end - time.monotonic()) + + +def rnfs(deadline, *args): + """Run an rnfs command within the deadline; its result (returncode, stdout bytes, stderr text).""" + result = subprocess.run([RNFS, *args], capture_output=True, timeout=deadline.left()) + return result.returncode, result.stdout, result.stderr.decode("utf-8", "replace").strip() + + +def take_lock(): + """Lock the page's lock file so requests run one at a time; the open file (closing it unlocks), or + None if another request holds the lock.""" + handle = open(os.path.join(PAGE_DIR, "lock"), "a+") + try: + try: + import fcntl + except ImportError: # Windows + import msvcrt + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return None + return handle + + +def over_rate_limit(): + """True if RATE_LIMIT requests already ran in the last RATE_WINDOW seconds; else count this one. + + Called with the lock held. Raises OSError if the state can't be saved, so the page fails closed. + """ + path = os.path.join(PAGE_DIR, "rate.json") + now = time.time() + try: + with open(path, encoding="utf-8") as f: + stamps = [stamp for stamp in json.load(f) + if isinstance(stamp, (int, float)) and now - RATE_WINDOW < stamp <= now] + except (OSError, ValueError, TypeError): + stamps = [] + if len(stamps) >= RATE_LIMIT: + return True + stamps.append(now) + with open(path + ".tmp", "w", encoding="utf-8") as f: + json.dump(stamps, f) + os.replace(path + ".tmp", path) + return False + + +def allowlist(): + """The CIDs in the operator's allowlist file (none if it doesn't exist).""" + try: + with open(ALLOWLIST, encoding="utf-8") as f: + words = [line.split("#", 1)[0].strip() for line in f] + except FileNotFoundError: + return set() + return {word for word in words if CID.fullmatch(word)} + + +def operator_pin(deadline, cid): + """cid's pin listing if the operator pinned it (source user or folder) and it is complete, else None. + + `rnfs pin ls` only reads the node's own records: it never fetches or provides anything. + """ + code, out, err = rnfs(deadline, "pin", "ls", "--json", cid) + if code != 0: + unavailable(cid, f"rnfs pin ls failed: {err}") + for pin in json.loads(out): + if pin.get("cid") == cid and pin.get("complete") and OPERATOR_SOURCES & set(pin.get("sources") or ()): + return pin + return None + + +def size_of(deadline, cid): """The file's size in bytes from rnfs stat, or None if it isn't known.""" - code, out, err = rnfs("stat", cid) + code, out, err = rnfs(deadline, "stat", cid) if code != 0: - fail(f"Couldn't find {cid} ({err or 'rnfs stat failed'}).") + unavailable(cid, f"rnfs stat failed: {err}") for line in out.decode("utf-8", "replace").splitlines(): name, _, value = line.partition(": ") if name == "size": @@ -59,42 +227,69 @@ def size_of(cid): return None -def is_directory(cid): - """True if the CID is a directory (rnfs ls lists it; for a file it fails with "is not a directory").""" - code, _, err = rnfs("ls", cid) - if code != 0 and "is not a directory" not in err: - fail(f"Couldn't read {cid} ({err or 'rnfs ls failed'}).") - return code == 0 - - -cid = os.environ.get("var_cid", "").strip() -if not CID.fullmatch(cid): - fail("No valid RNFS CID was given. Link to this page with `cid=.", - "Example: `_`[Example Link`:/page/index.mu`cid=bafkreiailvfecuuvt6tctx3owax2jic7y5af2ndriwo6xymcq2uwyut34a]`_") +def show(cid): + """Print the page for cid, or stop with a message saying why it isn't shown.""" + deadline = Deadline(TIMEOUT) + allowed = cid in allowlist() + pin = operator_pin(deadline, cid) + if pin is None and not allowed: + # Not this node's to share: run nothing that could fetch it (stat and cat would, and with + # provide_strategy "roots" the node would then provide what they fetched) + fail(f"{cid} isn't shared on this node.") + if pin is not None and pin.get("type") == "directory": + fail(f"{cid} is a folder, not a file.") -try: - size = size_of(cid) + size = size_of(deadline, cid) if size is None: fail(f"The size of {cid} isn't known, so it isn't shown.") if size > MAX_SIZE: fail(f"{cid} is {size:,} bytes; only files up to {MAX_SIZE // 1024} KB are shown.") - if is_directory(cid): - fail(f"{cid} is a folder, not a file.") - code, _, err = rnfs("pin", "add", cid) + # A bounded read: it fetches only the chunks covering MAX_SIZE bytes (a file that understates its size + # can't make it fetch more), pins nothing and is no daemon job, so nothing outlives this command. + # --raw: a sealed file is never decrypted here + code, content, err = rnfs(deadline, "cat", cid, "--raw", "--length", str(MAX_SIZE)) if code != 0: - fail(f"Couldn't pin {cid} ({err or 'rnfs pin add failed'}).") + unavailable(cid, f"rnfs cat failed: {err}") + if content.startswith(SEALED_MAGIC): + fail(f"{cid} is sealed (encrypted), so it isn't shown here.") + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + fail(f"{cid} isn't a text file, so it can't be shown here.") - code, content, err = rnfs("cat", cid) - if code != 0: - fail(f"Couldn't read {cid} ({err or 'rnfs cat failed'}).") -except subprocess.TimeoutExpired: - fail(f"Fetching {cid} took too long; try again later.") -except OSError as error: - fail(f"RNFS isn't available on this node ({error}).") - -try: - text = content.decode("utf-8") -except UnicodeDecodeError: - fail(f"{cid} isn't a text file, so it can't be shown here.") -print(text, end="" if text.endswith("\n") else "\n") + if allowed: # the operator vouches for it: Micron, as written + print(text, end="" if text.endswith("\n") else "\n") + else: + print("#!c=0") + print(BANNER) + print(plain_text(text)) + + +def main(): + """Check the request, take the lock and the rate limit, then show the file.""" + cid = os.environ.get("var_cid", "").strip() + if not CID.fullmatch(cid): + fail("No valid RNFS CID was given. Link to this page with `cid=.", + "Example: `_`[Example Link`:/page/index.mu`cid=bafkreiailvfecuuvt6tctx3owax2jic7y5af2ndriwo6xymcq2uwyut34a]`_") + try: + os.makedirs(PAGE_DIR, mode=0o700, exist_ok=True) + lock = take_lock() + except OSError as error: + log(f"can't use the page's data dir {PAGE_DIR}: {error}") + fail("This page isn't available right now; try again later.") + if lock is None: + fail("This page is busy with another request; try again in a moment.") + with lock: + try: + if over_rate_limit(): + fail("This page has had too many requests; try again in a minute.") + show(cid) + except subprocess.TimeoutExpired: + unavailable(cid, f"rnfs took longer than {TIMEOUT} s") + except Exception as error: # rnfs not installed, a state file that can't be written, odd CLI output... + unavailable(cid, f"{type(error).__name__}: {error}") + + +if __name__ == "__main__": + main() diff --git a/tests/security/_pages_helpers.py b/tests/security/_pages_helpers.py index 124e988..eb718fd 100644 --- a/tests/security/_pages_helpers.py +++ b/tests/security/_pages_helpers.py @@ -5,14 +5,17 @@ run the pages' own code against an offline ``RnfsNode`` (real node, fetcher, storage and publisher; the transport is the ``NetClient`` double from ``tests.test_node``). The CLI the pages call is stood in for by a fake ``subprocess.run`` that drives the in-process node, so the pages' real control flow, escaping and -``entry()`` logic run against real pin data. The slow, full-stack variants (a real daemon driven through the -real CLI) live in the test modules behind ``RNFS_E2E``. +``entry()`` logic run against real pin data. Each page keeps its allowlist, lock, rate state, caches and log +in a data dir, which the helpers point at a temporary directory through ``RNFS_PAGE_DIR``. The slow, +full-stack variants (a real daemon driven through the real CLI) live in the test modules behind ``RNFS_E2E``. """ +import contextlib import io import json import os import subprocess import sys +import tempfile from unittest import mock import RNS @@ -71,12 +74,35 @@ def index_page_namespace(): return namespace -def render_index(node): +@contextlib.contextmanager +def page_dir(path=None): + """RNFS_PAGE_DIR set to path, or to a fresh temporary directory removed afterwards; yields the path.""" + with contextlib.ExitStack() as stack: + if path is None: + path = stack.enter_context(tempfile.TemporaryDirectory()) + stack.enter_context(mock.patch.dict(os.environ, {"RNFS_PAGE_DIR": path})) + yield path + + +def render_index(node, calls=None, data_dir=None): """What the file-index-node page's entry() prints for each of node's complete pins (as the page does: - pin_ls JSON, round-tripped, through the page's own entry()).""" - namespace = index_page_namespace() + pin_ls JSON, round-tripped, through the page's own entry()). The rnfs commands entry() runs (its sealed + check) are served from node by fake_rnfs and recorded in calls; data_dir is the page's RNFS_PAGE_DIR.""" pins = json.loads(json.dumps(node.pin_ls())) # the CLI's pin ls --json, then the page's json.loads - return [namespace["entry"](pin) for pin in pins if pin.get("complete")] + with page_dir(data_dir), mock.patch.object(subprocess, "run", fake_rnfs(node, [] if calls is None else calls)): + namespace = index_page_namespace() + return [namespace["entry"](pin) for pin in pins if pin.get("complete")] + + +def exec_index_page(run, data_dir=None): + """Run the whole file-index-node script with subprocess.run replaced by run; return what it printed. + data_dir is the page's data dir (RNFS_PAGE_DIR; a fresh one if None).""" + with open(INDEX_PAGE, encoding="utf-8") as f: + source = f.read() + output = io.StringIO() + with page_dir(data_dir), mock.patch.object(subprocess, "run", run), mock.patch.object(sys, "stdout", output): + exec(compile(source, INDEX_PAGE, "exec"), {"__name__": "__main__"}) + return output.getvalue() # --- driving the display-mu-file page's whole script against an in-process node --- @@ -100,6 +126,18 @@ def fake_rnfs(node, calls, timeout_on=()): """ from rnfs.errors import NotFound + def cat(target, options): + """`rnfs cat target [--raw] [--offset N] [--length N]`, as the API's cat serves it (api.py api_cat).""" + raw = {"raw": True} if "--raw" in options else {} + offset = int(options[options.index("--offset") + 1]) if "--offset" in options else 0 + length = int(options[options.index("--length") + 1]) if "--length" in options else None + info = node.info(target, **raw) + if info["type"] != "file": + return _Completed(1, stderr=f"rnfs: {target} is a directory") + available = max(info["size"] - offset, 0) + length = available if length is None else min(length, available) + return _Completed(0, b"".join(node.read(target, offset=offset, length=length, **raw))) + def run(argv, *args, **kwargs): """Serve the page's rnfs(...) call from the node.""" words = list(argv[1:]) # argv[0] is the rnfs executable path @@ -116,9 +154,9 @@ def run(argv, *args, **kwargs): if words[:2] == ["pin", "add"]: return _Completed(0, (node.pin_add(words[2])["cid"] + "\n").encode("utf-8")) if words[0] == "cat": - return _Completed(0, b"".join(node.read(words[1]))) + return cat(words[1], words[2:]) if words[:3] == ["pin", "ls", "--json"]: - return _Completed(0, json.dumps(node.pin_ls()).encode("utf-8")) + return _Completed(0, json.dumps(node.pin_ls(*words[3:4])).encode("utf-8")) except ValueError as error: # e.g. " is not a directory", which the page keys off return _Completed(1, stderr=str(error)) except NotFound as error: @@ -128,13 +166,25 @@ def run(argv, *args, **kwargs): return run -def exec_display_page(run, cid): +def display_page_namespace(data_dir): + """The display-mu-file page's module-level names (its constants and functions), without running it, for + the page data dir data_dir.""" + with open(DISPLAY_PAGE, encoding="utf-8") as f: + source = f.read() + namespace = {"__name__": "display_page"} + with page_dir(data_dir): + exec(compile(source, DISPLAY_PAGE, "exec"), namespace) + return namespace + + +def exec_display_page(run, cid, data_dir=None): """Run the real display-mu-file script for var_cid=cid with subprocess.run replaced by run; return - whatever the page printed.""" + whatever the page printed. data_dir is the page's data dir (RNFS_PAGE_DIR; a fresh one if None).""" with open(DISPLAY_PAGE, encoding="utf-8") as f: source = f.read() output = io.StringIO() - with mock.patch.object(subprocess, "run", run), \ + with page_dir(data_dir), \ + mock.patch.object(subprocess, "run", run), \ mock.patch.dict(os.environ, {"var_cid": cid}), \ mock.patch.object(sys, "stdout", output): try: @@ -144,13 +194,19 @@ def exec_display_page(run, cid): return output.getvalue() -def run_display_page(node, cid, timeout_on=()): +def run_display_page(node, cid, timeout_on=(), data_dir=None): """Run the real display-mu-file script for var_cid=cid against node; return (output, calls).""" calls = [] - output = exec_display_page(fake_rnfs(node, calls, timeout_on), cid) + output = exec_display_page(fake_rnfs(node, calls, timeout_on), cid, data_dir) return output, calls +def allow(data_dir, *cids): + """Write the display page's allowlist file in data_dir, listing cids.""" + with open(os.path.join(data_dir, "allowlist.txt"), "w", encoding="utf-8") as f: + f.write("# CIDs the operator lets the page fetch and render\n" + "".join(f"{cid}\n" for cid in cids)) + + # --- the N1 "1-byte" DAG bomb --- diff --git a/tests/security/test_h2_display_page_pins.py b/tests/security/test_h2_display_page_pins.py index 5620edf..5ad2c49 100644 --- a/tests/security/test_h2_display_page_pins.py +++ b/tests/security/test_h2_display_page_pins.py @@ -1,12 +1,14 @@ -"""H2: one anonymous view of the display-mu-file page makes the node permanently pin (source "user") and -provide content the visitor chose. The CID comes from var_cid; the page runs stat, ls, `pin add` and cat on -it with no allowlist and no per-visitor limit (index.mu:70-100). `pin add` pins USER (node.py:821-833), which -the node then provides; and with the default provide_strategy="roots", the page's stat/cat alone already make -a cached complete fetch be provided, so dropping only the pin would not stop the seeding. - -The in-process tests run the page's real script against an offline victim node that fetches from an offline -provider (the NetClient double). The job-leak variant (the daemon job surviving the page's SIGKILL) needs the -real daemon/CLI process split and is gated behind RNFS_E2E. +"""H2: one anonymous view of the display-mu-file page made the node permanently pin (source "user") and +provide content the visitor chose. The CID comes from var_cid; the page ran stat, ls, `pin add` and cat on it +with no allowlist and no per-visitor limit. `pin add` pins USER (node.py:821-833), which the node then +provides; and with the default provide_strategy="roots", stat/cat alone already make a cached complete fetch +be provided, so dropping only the pin would not stop the seeding. + +The fixed page shows only the operator's own complete pins (source user or folder) and CIDs in the operator's +allowlist file; for anything else it runs nothing but `rnfs pin ls`, which never fetches. Allowlisted CIDs are +read with a bounded `rnfs cat` (no pin, no daemon job). Requests run one at a time under a lock file and a +rate limit. The in-process tests run the page's real script against an offline victim node that could fetch +the CID from an offline provider (the NetClient double). The real-daemon variants are gated behind RNFS_E2E. """ import io import json @@ -14,7 +16,9 @@ import subprocess import sys import tempfile +import time import unittest +from unittest import mock import RNS @@ -22,6 +26,8 @@ from rnfs.content.storage import USER from tests.security import _pages_helpers as helpers +CONTENT = b"hello from the visitor's chosen content\n" +MICRON = b">Allowlisted page\nA `!bold`! word and a `[link`:/page/index.mu]\n" _saved_loglevel = RNS.loglevel @@ -36,34 +42,70 @@ def tearDownModule(): RNS.loglevel = _saved_loglevel -class DisplayPagePinTest(unittest.TestCase): - """The display page driven against an in-process victim that can fetch the CID from a provider.""" +class PageTestCase(unittest.TestCase): + """A provider A and a victim B that serves the page, and a data dir for the page.""" def setUp(self): - """A provider A holding a small file, and a victim B that serves the page.""" + """Two offline nodes joined by a NetClient, and the page's data dir.""" self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) _, self.provider, self.victim = helpers.make_nodes(self.tmp.name) self.addCleanup(self.provider.stop) self.addCleanup(self.victim.stop) - self.cid = self.provider.add_stream(io.BytesIO(b"hello from the visitor's chosen content\n"))["cid"] - helpers.advertise(self.provider, self.victim, self.cid) + self.data_dir = os.path.join(self.tmp.name, "page-data") + os.makedirs(self.data_dir) + + def provide(self, content: bytes) -> str: + """Add content on the provider and advertise it to the victim; its CID.""" + cid = self.provider.add_stream(io.BytesIO(content))["cid"] + helpers.advertise(self.provider, self.victim, cid) + return cid + + def view(self, cid, **kwargs): + """Run the page for cid against the victim, with this test's data dir; (output, calls).""" + return helpers.run_display_page(self.victim, cid, data_dir=self.data_dir, **kwargs) + + def page(self, name): + """One of the page's module-level names (a constant or function), for this test's data dir.""" + return helpers.display_page_namespace(self.data_dir)[name] + + def stored_blocks(self, cid: str) -> int: + """How many blocks of cid's DAG the victim stores.""" + return self.victim.storage.stat(Cid.parse(cid))["local_blocks"] + + +class DisplayPagePinTest(PageTestCase): + """A visitor asks for a CID the operator never chose.""" + + def setUp(self): + """A small file on the provider, which the victim could fetch.""" + super().setUp() + self.cid = self.provide(CONTENT) self.key = Cid.parse(self.cid) - @unittest.expectedFailure # H2: fails until the page only pins CIDs the operator allowlisted def test_page_view_does_not_pin_a_non_allowlisted_cid(self): """A single page view for a CID the operator never chose must not create a permanent user pin.""" - output, calls = helpers.run_display_page(self.victim, self.cid) - self.assertIn(b"hello from the visitor's chosen content\n", output.encode()) # the page served it + output, calls = self.view(self.cid) + self.assertNotIn(CONTENT.decode(), output) # nor serve it + self.assertIn("isn't shared on this node", output) self.assertNotIn(USER, self.victim.storage.pins().get(self.key, set())) - @unittest.expectedFailure # H2: fails until a page view for a non-allowlisted CID never makes the node provide def test_page_view_does_not_make_the_node_provide(self): """After a page view, the node must not advertise itself as a provider of the visitor's CID.""" - helpers.run_display_page(self.victim, self.cid) + self.view(self.cid) self.assertNotIn(self.key, self.victim.publisher.provided()) - @unittest.expectedFailure # H2: fails until the page's read path (stat/cat) does not provide either + def test_page_runs_nothing_that_fetches_a_cid_it_does_not_share(self): + """The page only lists the CID's pins (which never fetches): no stat, ls, cat or pin add, so not + even the root block is fetched, and the "roots" strategy has nothing to provide.""" + _, calls = self.view(self.cid) + self.assertEqual(calls, [["pin", "ls", "--json", self.cid]]) + self.assertEqual(self.stored_blocks(self.cid), 0) + + # H2 (core): fails while `rnfs stat` / `rnfs cat` of remote content make the node provide it under + # provide_strategy="roots". The page no longer runs them for CIDs it doesn't share (the test above); + # a CLI read that doesn't provide (like the gateway's for_gateway reads) needs a core change. + @unittest.expectedFailure def test_stat_and_cat_alone_make_the_node_provide(self): """Removing only the `pin add` isn't enough: with provide_strategy="roots", the page's remaining stat and cat calls make the complete cached fetch be provided.""" @@ -71,18 +113,195 @@ def test_stat_and_cat_alone_make_the_node_provide(self): b"".join(self.victim.read(self.cid)) # the page's `rnfs cat` self.assertNotIn(self.key, self.victim.publisher.provided()) + def test_contact_pin_is_not_shown(self): + """A complete pin that a contact asked for (a remote: source), not the operator, isn't shown.""" + b"".join(self.victim.read(self.cid)) # fetched and cached... + self.victim.storage.pin(self.key, "remote:" + "ab" * 16) # ...and pinned for a contact + output, calls = self.view(self.cid) + self.assertNotIn(CONTENT.decode(), output) + self.assertIn("isn't shared on this node", output) + self.assertEqual(calls, [["pin", "ls", "--json", self.cid]]) + + +class OperatorPinTest(PageTestCase): + """CIDs the operator pinned on the victim itself.""" + + def test_operator_pin_is_shown_without_fetching_or_pinning(self): + """A complete user pin is shown, read with a bounded raw cat; no pin add, ls or other command runs.""" + cid = self.victim.add_stream(io.BytesIO(b"the operator's notes\n"))["cid"] + output, calls = self.view(cid) + self.assertIn("the operator's notes", output) + self.assertEqual(calls, [["pin", "ls", "--json", cid], ["stat", cid], + ["cat", cid, "--raw", "--length", str(self.page("MAX_SIZE"))]]) + + def test_sealed_operator_pin_is_not_shown(self): + """A file sealed to this node is never decrypted for a visitor (cat --raw) and never shown.""" + helpers.give_identity(self.victim) + cid = self.victim.add_stream(io.BytesIO(b"private plaintext\n"), to_self=True)["cid"] + output, _ = self.view(cid) + self.assertNotIn("private plaintext", output) + self.assertIn("sealed", output) + + def test_folder_pin_is_refused_as_a_folder(self): + """A pinned directory isn't a file to show, and nothing more than its pin listing is read.""" + folder = os.path.join(self.tmp.name, "shared") + os.makedirs(folder) + with open(os.path.join(folder, "a.txt"), "wb") as f: + f.write(b"a") + cid = self.victim.add_path(folder)["cid"] + output, calls = self.view(cid) + self.assertIn("is a folder", output) + self.assertEqual(calls, [["pin", "ls", "--json", cid]]) + + +class AllowlistTest(PageTestCase): + """CIDs listed in the operator's allowlist file may be fetched, and only they are rendered as Micron.""" + + def test_allowlisted_cid_is_fetched_and_rendered_as_micron(self): + """An allowlisted CID the node doesn't have is read from the network and printed as Micron, as + written; it is not pinned and no pin add (a daemon job) is started.""" + cid = self.provide(MICRON) + helpers.allow(self.data_dir, cid) + output, calls = self.view(cid) + self.assertEqual(output, MICRON.decode()) + self.assertNotIn(["pin", "add", cid], calls) + self.assertIn(["cat", cid, "--raw", "--length", str(self.page("MAX_SIZE"))], calls) + self.assertNotIn(Cid.parse(cid), self.victim.storage.pins()) + + def test_allowlist_ignores_comments_and_junk(self): + """Comments, blank lines and words that aren't CIDs are skipped; a CID with a comment after it counts.""" + cid = self.provide(MICRON) + with open(os.path.join(self.data_dir, "allowlist.txt"), "w", encoding="utf-8") as f: + f.write(f"# pages I vouch for\n\n../etc/passwd\n--help\n{cid} # my front page\n") + self.assertEqual(self.page("allowlist")(), {cid}) + output, _ = self.view(cid) + self.assertEqual(output, MICRON.decode()) + + def test_allowlisted_cid_over_max_size_is_not_read(self): + """MAX_SIZE still holds for allowlisted CIDs: a larger file is refused after stat (its root only).""" + cid = self.provide(os.urandom(self.page("MAX_SIZE") + 1)) + helpers.allow(self.data_dir, cid) + output, calls = self.view(cid) + self.assertIn("only files up to", output) + self.assertEqual([call[0] for call in calls], ["pin", "stat"]) + self.assertEqual(self.stored_blocks(cid), 1) # the root, which stat needs for the size + + def test_allowlisted_timeout_shows_a_generic_message_and_logs_it(self): + """If the read outlives the page's deadline the visitor gets a short message; the log says why.""" + cid = self.provide(MICRON) + helpers.allow(self.data_dir, cid) + output, _ = self.view(cid, timeout_on=("cat",)) + self.assertIn("can't be shown right now", output) + with open(os.path.join(self.data_dir, "page.log"), encoding="utf-8") as f: + self.assertIn("took longer than", f.read()) + + def test_page_deadline_is_shared_by_its_commands(self): + """The page gives all its rnfs commands together at most TIMEOUT seconds, not TIMEOUT each.""" + cid = self.victim.add_stream(io.BytesIO(b"notes\n"))["cid"] + timeouts = [] + run = helpers.fake_rnfs(self.victim, []) + clock = [1000.0] + + def slow_run(argv, *args, **kwargs): + """Record the timeout the page passes, and let 50 seconds pass for each command.""" + timeouts.append(kwargs["timeout"]) + clock[0] += 50 + return run(argv, *args, **kwargs) + + with mock.patch.object(time, "monotonic", lambda: clock[0]): + helpers.exec_display_page(slow_run, cid, self.data_dir) + timeout = self.page("TIMEOUT") + self.assertEqual(timeouts, [timeout, timeout - 50, timeout - 100]) + + +class LockAndRateLimitTest(PageTestCase): + """Page requests run one at a time and at most RATE_LIMIT per RATE_WINDOW; both fail closed.""" -# --- full-stack variant: a real daemon, driven through the real CLI, with the page's SIGKILL --- + def setUp(self): + """A CID the victim doesn't share: a view of it only lists pins.""" + super().setUp() + self.cid = self.provide(CONTENT) + + def rate_file(self, stamps=None): + """Write the rate state (if stamps is given); the stamps saved there.""" + path = os.path.join(self.data_dir, "rate.json") + if stamps is not None: + with open(path, "w", encoding="utf-8") as f: + json.dump(stamps, f) + with open(path, encoding="utf-8") as f: + return json.load(f) + + def test_a_request_in_progress_makes_the_next_one_busy(self): + """While another request holds the lock, the page runs no rnfs command and says it is busy.""" + lock = self.page("take_lock")() # as a request in progress holds it + self.assertIsNotNone(lock) + with lock: + output, calls = self.view(self.cid) + self.assertIn("busy", output) + self.assertEqual(calls, []) + _, calls = self.view(self.cid) # released: the next request runs + self.assertEqual(calls, [["pin", "ls", "--json", self.cid]]) + + def test_views_past_the_rate_limit_are_refused(self): + """RATE_LIMIT views run; the next one, within RATE_WINDOW, runs no rnfs command.""" + for _ in range(self.page("RATE_LIMIT")): + _, calls = self.view(self.cid) + self.assertEqual(len(calls), 1) + output, calls = self.view(self.cid) + self.assertIn("too many requests", output) + self.assertEqual(calls, []) + + def test_old_views_no_longer_count(self): + """Views older than RATE_WINDOW are forgotten; junk and future stamps don't count either.""" + now = time.time() + old = now - self.page("RATE_WINDOW") - 1 + self.rate_file([old] * self.page("RATE_LIMIT") + ["junk", now + 3600]) + _, calls = self.view(self.cid) + self.assertEqual(len(calls), 1) + self.assertEqual(len(self.rate_file()), 1) # only this view + + def test_a_full_window_is_refused(self): + """RATE_LIMIT views within the window (e.g. from earlier requests) make the next one wait.""" + self.rate_file([time.time() - 1] * self.page("RATE_LIMIT")) + output, calls = self.view(self.cid) + self.assertIn("too many requests", output) + self.assertEqual(calls, []) + + def test_an_unusable_data_dir_fails_closed(self): + """If the page can't use its data dir (here a file is in the way), it runs nothing.""" + blocked = os.path.join(self.tmp.name, "not-a-dir") + with open(blocked, "w", encoding="utf-8"): + pass + output, calls = helpers.run_display_page(self.victim, self.cid, data_dir=blocked) + self.assertIn("isn't available right now", output) + self.assertEqual(calls, []) + + def test_unsaved_rate_state_fails_closed(self): + """If the rate state can't be saved, the page doesn't run without its limit.""" + os.makedirs(os.path.join(self.data_dir, "rate.json.tmp")) # in the way of the atomic write + output, calls = self.view(self.cid) + self.assertIn("can't be shown right now", output) + self.assertEqual(calls, []) + + def test_an_invalid_cid_runs_nothing_and_is_not_counted(self): + """A var_cid that isn't a bare CID never reaches rnfs, the lock or the rate state.""" + output, calls = self.view("--help") + self.assertIn("No valid RNFS CID", output) + self.assertEqual(calls, []) + self.assertFalse(os.path.exists(os.path.join(self.data_dir, "rate.json"))) + + +# --- full-stack variant: a real daemon, driven through the real CLI --- E2E = unittest.skipUnless(os.environ.get("RNFS_E2E") == "1", "set RNFS_E2E=1 to run") @E2E class DisplayPageDaemonTest(unittest.TestCase): - """Run the actual page script as a subprocess against a real victim daemon served by a real provider.""" + """Run the actual page script against a real victim daemon served by a real provider.""" def setUp(self): - """A hub (transport), a provider and a victim daemon over loopback TCP.""" + """A hub (transport), a provider and a victim daemon over loopback TCP, and the page's data dir.""" from tests import test_e2e self.e2e = test_e2e self.tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) @@ -94,6 +313,9 @@ def setUp(self): bootstrap = ("--bootstrap", self.hub.node_id) self.provider = self._start("prov", test_e2e.NODE_CONFIG, tcp_port, *bootstrap) self.victim = self._start("victim", test_e2e.NODE_CONFIG, tcp_port, *bootstrap) + self.data_dir = os.path.join(self.tmp.name, "page-data") + os.makedirs(self.data_dir) + self.commands = os.path.join(self.tmp.name, "commands.log") # what the page ran, one per line def _start(self, name, config, tcp_port, *extra): """Start one daemon and remember it for cleanup.""" @@ -109,81 +331,100 @@ def _stop_all(self): daemon.stop() def _shim(self, daemon): - """A directory holding an `rnfs` executable that drives daemon through the worktree's CLI.""" + """A directory holding an `rnfs` executable that logs its arguments and drives daemon through the + worktree's CLI.""" bindir = os.path.join(self.tmp.name, f"bin-{daemon.name}") os.makedirs(bindir, exist_ok=True) shim = os.path.join(bindir, "rnfs") with open(shim, "w", encoding="utf-8") as f: - f.write(f'#!/bin/sh\nexec "{sys.executable}" "{self.e2e.MAIN}" ' + f.write(f'#!/bin/sh\necho "$*" >> "{self.commands}"\nexec "{sys.executable}" "{self.e2e.MAIN}" ' f'--dir "{daemon.dir}" --api-port {daemon.api_port} "$@"\n') os.chmod(shim, 0o755) return bindir - def _run_page(self, daemon, cid, env_extra=None): - """Run the display-mu-file page as NomadNet would, against daemon, for var_cid=cid.""" - env = {**os.environ, "PATH": self._shim(daemon) + os.pathsep + os.environ.get("PATH", ""), - "var_cid": cid, **(env_extra or {})} - return subprocess.Popen([sys.executable, helpers.DISPLAY_PAGE], env=env, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) + def _path(self, daemon): + """PATH with daemon's shim first.""" + return self._shim(daemon) + os.pathsep + os.environ.get("PATH", "") - @unittest.expectedFailure # H2: fails until the page does not permanently pin non-allowlisted CIDs - def test_page_view_pins_as_user_on_a_real_daemon(self): - """The real page, run against a real daemon, leaves a permanent user pin for the visitor's CID.""" + def _run_page(self, daemon, cid): + """Run the display-mu-file page as NomadNet would, against daemon, for var_cid=cid; its stdout.""" + env = {**os.environ, "PATH": self._path(daemon), "var_cid": cid, "RNFS_PAGE_DIR": self.data_dir} + page = subprocess.run([sys.executable, helpers.DISPLAY_PAGE], env=env, capture_output=True, timeout=300) + self.assertEqual(page.returncode, 0, page.stderr.decode("utf-8", "replace")) + return page.stdout + + def _provide(self, content: bytes) -> str: + """Add content on the provider and wait until the victim finds it; its CID.""" path = os.path.join(self.tmp.name, "content.txt") with open(path, "wb") as f: - f.write(b"visitor chosen bytes\n") - cid = self.provider.out("add", path) - self.e2e.wait_for(lambda: self.provider.node_id in self._providers(self.victim, cid), 60, - "victim to find the provider") - page = self._run_page(self.victim, cid) - out, err = page.communicate(timeout=180) - self.assertEqual(page.returncode, 0, err.decode("utf-8", "replace")) - self.assertIn(b"visitor chosen bytes", out) - pins = {pin["cid"]: pin for pin in json.loads(self.victim.out("pin", "ls", "--json"))} - self.assertNotIn(cid, pins, "the page left a permanent pin for a visitor-chosen CID") - - @unittest.expectedFailure # H2: fails until killing the page's pin add cancels the daemon job - def test_killed_pin_add_still_pins_on_the_daemon(self): - """The page pins with `rnfs pin add` and SIGKILLs it on timeout; the daemon finishes the pin anyway. - - The page runs `rnfs pin add` as a subprocess and SIGKILLs it when it times out. follow() cancels the - daemon job only on KeyboardInterrupt, so SIGKILL leaves the job running: the daemon fetches and - permanently pins the content even though the page's CLI is gone. Here the CLI is SIGKILLed as soon as - its pin job is running, and the victim still ends up with the CID pinned. - """ - path = os.path.join(self.tmp.name, "big.bin") - with open(path, "wb") as f: - f.write(os.urandom(3 * 1024 * 1024)) # large enough that the fetch is still running when killed + f.write(content) cid = self.provider.out("add", path) self.e2e.wait_for(lambda: self.provider.node_id in self._providers(self.victim, cid), 60, "victim to find the provider") - proc = subprocess.Popen( - [sys.executable, self.e2e.MAIN, "--dir", self.victim.dir, "--api-port", str(self.victim.api_port), - "pin", "add", cid], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, **self.e2e.POPEN_OPTIONS) - try: - self.e2e.wait_for(lambda: self._active_targets(self.victim), 30, "the pin job to start running") - proc.kill() # SIGKILL, as subprocess.run(timeout=...) does on timeout - proc.wait(timeout=20) - # No `what`, so a timeout returns False (not pinned) instead of raising - pinned = self.e2e.wait_for( - lambda: cid in [pin["cid"] for pin in json.loads(self.victim.out("pin", "ls", "--json"))], 60) - self.assertFalse(pinned, "the daemon pinned the content after the page's CLI was killed") - finally: - if proc.poll() is None: - proc.kill() - - def _active_targets(self, daemon): - """Targets of the daemon's queued or running jobs.""" - lines = daemon.out("jobs", "list").splitlines() - return [line.split()[-1] for line in lines - if len(line.split()) >= 3 and line.split()[1] in ("queued", "running")] + return cid + + def _pinned(self, daemon): + """CIDs daemon has pinned.""" + return [pin["cid"] for pin in json.loads(daemon.out("pin", "ls", "--json"))] + + def _ran(self): + """The rnfs commands the page ran, as argument strings.""" + if not os.path.exists(self.commands): + return [] + with open(self.commands, encoding="utf-8") as f: + return f.read().splitlines() + + def _jobs(self, daemon): + """daemon's job list lines (none if it has no jobs).""" + return [line for line in daemon.out("jobs", "list").splitlines() if line.strip() and line != "no jobs"] def _providers(self, daemon, cid): """Provider node IDs daemon finds for cid.""" result = daemon.cli("providers", cid, check=False) return result.stdout.decode("utf-8", "replace").split() if result.returncode == 0 else [] + def test_page_view_does_not_fetch_on_a_real_daemon(self): + """The real page, run against a real daemon for a CID the operator never chose, only lists pins: + it shows nothing, pins nothing and fetches nothing.""" + cid = self._provide(b"visitor chosen bytes\n") + out = self._run_page(self.victim, cid) + self.assertNotIn(b"visitor chosen bytes", out) + self.assertIn(b"isn't shared on this node", out) + self.assertEqual(self._ran(), [f"pin ls --json {cid}"]) + self.assertNotIn(cid, self._pinned(self.victim), "the page left a permanent pin for a visitor-chosen CID") + + def test_allowlisted_view_leaves_no_pin_or_job_on_a_real_daemon(self): + """An allowlisted CID is fetched and rendered as Micron, with no pin and no daemon job left behind.""" + cid = self._provide(MICRON) + helpers.allow(self.data_dir, cid) + out = self._run_page(self.victim, cid) + self.assertEqual(out, MICRON) + self.assertNotIn(cid, self._pinned(self.victim)) + self.assertEqual(self._jobs(self.victim), []) + self.assertFalse(any(line.startswith("pin add") for line in self._ran())) + + def test_page_timeout_leaves_no_job_on_the_daemon(self): + """The old page SIGKILLed its foreground `rnfs pin add` on timeout and the daemon finished the job + anyway. The page's read is now a plain `rnfs cat`: when the page's deadline kills it, no job is left + queued or running and the CID is never pinned.""" + cid = self._provide(os.urandom(75 * 1024).hex().encode()) # 150 KB of text: several chunks + helpers.allow(self.data_dir, cid) + real_run = subprocess.run + + def run(argv, *args, **kwargs): + """The real CLI, with the cat's deadline cut so short that the page kills it, as on a slow link.""" + if argv[1:2] == ["cat"]: + kwargs["timeout"] = 0.05 + return real_run(argv, *args, **kwargs) + + with mock.patch.dict(os.environ, {"PATH": self._path(self.victim)}): + output = helpers.exec_display_page(run, cid, self.data_dir) + self.assertIn("can't be shown right now", output) + self.assertTrue(any(line.startswith("cat ") for line in self._ran())) + self.assertEqual(self._jobs(self.victim), []) + # No `what`, so a timeout returns False (never pinned) instead of raising + self.assertFalse(self.e2e.wait_for(lambda: cid in self._pinned(self.victim), 10)) + def helpers_free_port(): """A free local TCP port (support.free_port, imported lazily so the module loads without RNFS_E2E).""" diff --git a/tests/security/test_l11_display_page_micron.py b/tests/security/test_l11_display_page_micron.py new file mode 100644 index 0000000..d7a36fa --- /dev/null +++ b/tests/security/test_l11_display_page_micron.py @@ -0,0 +1,102 @@ +"""L11: the display-mu-file page printed whatever the file held as Micron, so content someone else wrote +(phishing forms, links, pages imitating the operator's own) appeared under the node's address. + +The fixed page renders Micron only for CIDs in the operator's allowlist file. Anything else it shows (the +operator's own complete pins) is printed as plain text: a banner saying it is third-party content, then one +literal block (`=) holding the text, in which no line is markup. The tests run the page's real script against +an offline node holding a file full of Micron. +""" +import io +import os +import tempfile +import unittest + +import RNS + +from tests.security import _pages_helpers as helpers + +_saved_loglevel = RNS.loglevel + +# A page someone else wrote: a fake heading, a link, a password field, colour headers, an early end of the +# literal block (plainly, and hidden behind a combining mark NomadNet drops), controls and a bidi override. +HOSTILE = "\n".join([ + ">Official downloads", + "`[Click to verify your identity`:/page/phish.mu]", + "Password: `", + "#!bg=fff", + "#!fg=fff", + "`=", + ">After an early end of the block", + "`́=", + "`{f64a846313b874ee4a357040807f8c77:/page/partial.mu", + "\x1b[2Jcleared\tscreen", + "‮evil", + "-", + "# a comment hiding text", +]) + "\n" + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class PlainTextTest(unittest.TestCase): + """A file full of Micron, pinned by the operator, shown through the page.""" + + def setUp(self): + """An offline node that has the hostile file pinned (source user), and the page's data dir.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.node, _ = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.node.stop) + self.cid = self.node.add_stream(io.BytesIO(HOSTILE.encode("utf-8")))["cid"] + self.data_dir = os.path.join(self.tmp.name, "page-data") + os.makedirs(self.data_dir) + + def view(self): + """The page's output lines for the hostile file.""" + output, _ = helpers.run_display_page(self.node, self.cid, data_dir=self.data_dir) + return output.splitlines() + + def test_content_is_one_literal_block_under_a_banner(self): + """The banner comes first; then exactly one literal block, opened and closed by the page itself, + holds every line of the file, so the file's own "`=" lines can't end it early.""" + lines = self.view() + self.assertEqual(lines[0], "#!c=0") + start = lines.index("`=") + self.assertIn("Third-party content", "\n".join(lines[:start])) + self.assertEqual(lines[-1], "`=") + self.assertEqual([i for i, line in enumerate(lines) if line == "`="], [start, len(lines) - 1]) + self.assertEqual(len(lines) - start - 2, len(HOSTILE.splitlines())) # one block line per file line + self.assertIn(">Official downloads", lines[start + 1:]) # shown, but inside the block + + def test_content_cannot_restyle_the_page(self): + """NomadNet takes "#!bg=" / "#!fg=" from anywhere in a page; the file's can't reach it, so it can't + hide the banner by painting the page's text in its background colour.""" + page = "\n".join(self.view()) + self.assertNotIn("#!bg=", page) + self.assertNotIn("#!fg=", page) + + def test_content_is_printable(self): + """Control characters (a terminal escape) and bidi overrides don't reach the visitor's terminal. The + only non-printable character is the page's own word joiner splitting "#!" (NomadNet drops it before + parsing, after its search for "#!bg=").""" + for line in self.view(): + self.assertTrue(line.replace("#\u2060!", "#!").isprintable(), repr(line)) + + def test_allowlisted_content_is_micron(self): + """The same file, once the operator allowlists it, is rendered as written, without the banner.""" + helpers.allow(self.data_dir, self.cid) + output, _ = helpers.run_display_page(self.node, self.cid, data_dir=self.data_dir) + self.assertEqual(output, HOSTILE) + self.assertNotIn("Third-party content", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l14_page_local_paths.py b/tests/security/test_l14_page_local_paths.py index be2c6e8..3c9055b 100644 --- a/tests/security/test_l14_page_local_paths.py +++ b/tests/security/test_l14_page_local_paths.py @@ -1,11 +1,13 @@ """L14: the pages show daemon errors and CLI stderr to remote visitors, which can carry local filesystem -paths (for example "no API token at /...: is the daemon running"). The display-mu-file page echoes +paths (for example "no API token at /...: is the daemon running"). The display-mu-file page echoed the CLI's stderr verbatim on every error path (index.mu:54, 66, 86, 90, 94). This runs the page's real script with a CLI that fails exactly as rnfs does when the daemon is down, and -checks the visitor is not shown the local path the error carries. +checks the visitor is not shown the local path the error carries. The fixed pages show a generic message and +write the detail to page.log in their data dir; the tests check that too. """ import os +import tempfile import unittest import RNS @@ -32,15 +34,55 @@ def tearDownModule(): class LocalPathLeakTest(unittest.TestCase): """The display page run against a CLI whose stderr carries a local path.""" - @unittest.expectedFailure # L14: fails until the page shows a generic message and logs the detail locally + def setUp(self): + """A data dir for the page (its log goes there).""" + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.data_dir = tmp.name + + def log(self) -> str: + """The page's log.""" + with open(os.path.join(self.data_dir, "page.log"), encoding="utf-8") as f: + return f.read() + def test_display_page_hides_cli_stderr_paths(self): - """A failing `rnfs stat` must not leak its local path to the visitor.""" + """A failing rnfs command (`rnfs pin ls`, the page's first) must not leak its local path to the visitor.""" def run(argv, *args, **kwargs): """Fail as the CLI does when the daemon is down.""" return helpers._Completed(1, stderr=DAEMON_DOWN) - output = helpers.exec_display_page(run, CID) + output = helpers.exec_display_page(run, CID, self.data_dir) self.assertNotIn(RNFS_DIR, output) + self.assertIn("can't be shown right now", output) + self.assertIn(DAEMON_DOWN, self.log()) # the operator still gets the detail + + def test_display_page_hides_exceptions(self): + """An rnfs that isn't installed (the OSError names its path) gives the same generic message.""" + missing = os.path.join(os.path.expanduser("~"), ".local", "bin", "rnfs") + + def run(argv, *args, **kwargs): + """Fail as subprocess.run does for a missing executable.""" + raise FileNotFoundError(2, "No such file or directory", missing) + + output = helpers.exec_display_page(run, CID, self.data_dir) + self.assertNotIn(missing, output) + self.assertIn("can't be shown right now", output) + self.assertIn(missing, self.log()) + + def test_log_is_kept_small(self): + """Past LOG_MAX bytes the log moves to page.log.1, so visitors causing errors can't fill the disk.""" + log_max = helpers.display_page_namespace(self.data_dir)["LOG_MAX"] + with open(os.path.join(self.data_dir, "page.log"), "w", encoding="utf-8") as f: + f.write("x" * (log_max + 1)) + + def run(argv, *args, **kwargs): + """Fail as the CLI does when the daemon is down.""" + return helpers._Completed(1, stderr=DAEMON_DOWN) + + helpers.exec_display_page(run, CID, self.data_dir) + self.assertEqual(os.path.getsize(os.path.join(self.data_dir, "page.log.1")), log_max + 1) + self.assertLess(len(self.log()), 1000) + self.assertIn(DAEMON_DOWN, self.log()) if __name__ == "__main__": diff --git a/tests/security/test_n1_display_page_unbounded.py b/tests/security/test_n1_display_page_unbounded.py index 8de9058..f893564 100644 --- a/tests/security/test_n1_display_page_unbounded.py +++ b/tests/security/test_n1_display_page_unbounded.py @@ -1,14 +1,15 @@ -"""N1 (the H2 + L3 chain): one anonymous view of the display-mu-file page makes the node download and +"""N1 (the H2 + L3 chain): one anonymous view of the display-mu-file page made the node download and permanently pin an unbounded DAG, bypassing the page's 200 KB MAX_SIZE check. A file root may truthfully declare 1 byte while carrying many zero-size children (L3: no reader rejects size-0 file-node children). The -page's size_of() reads that declared 1 byte, so the MAX_SIZE check passes (index.mu:76-80); `pin add` then -fetches the whole DAG with no max_dag_bytes and pins it USER (node.py:821-826), bounded only by the disk -reserve. +page's size_of() read that declared 1 byte, so the MAX_SIZE check passed; `pin add` then fetched the whole +DAG with no max_dag_bytes and pinned it USER (node.py:821-826), bounded only by the disk reserve. -The test runs the page's real script against an offline victim that fetches the crafted DAG from an offline -provider, and asserts the node did not store far more than MAX_SIZE. The real-daemon variant is gated behind -RNFS_E2E. +The tests run the page's real script against an offline victim that could fetch the crafted DAG from an +offline provider, and assert the node did not store far more than MAX_SIZE: for a visitor's CID (which the +fixed page doesn't fetch at all) and for an allowlisted one (which it reads with a bounded `rnfs cat` instead +of `pin add`; cat's read_range skips the empty subtrees). """ +import os import tempfile import unittest @@ -47,14 +48,10 @@ def setUp(self): self.root, blocks = helpers.bomb_dag(fanout=5, children=3000) helpers.serve_blocks(self.provider, self.victim, self.root, blocks) - @unittest.expectedFailure # N1: fails until a page view can't store more than MAX_SIZE (+ overhead) of DAG def test_one_page_view_cannot_store_more_than_max_size(self): - """The page admits the 1-byte-declared root, then pins a DAG many times larger than MAX_SIZE.""" - declared = self.victim.stat(str(self.root))["size"] - self.assertEqual(declared, 1) # what the page's MAX_SIZE check sees - + """The old page admitted the 1-byte-declared root, then pinned a DAG many times larger than MAX_SIZE.""" _, calls = helpers.run_display_page(self.victim, str(self.root)) - self.assertIn(["pin", "add", str(self.root)], calls) # the MAX_SIZE check did not stop it + self.assertNotIn(["pin", "add", str(self.root)], calls) stored = self.victim.storage.used_bytes() self.assertLessEqual( @@ -62,12 +59,30 @@ def test_one_page_view_cannot_store_more_than_max_size(self): f"a single page view of a 1-byte-declared root stored {stored} bytes " f"in {self.victim.storage.block_count()} blocks") - @unittest.expectedFailure # N1: fails until the node does not permanently pin an understated DAG def test_bomb_is_not_permanently_pinned(self): - """The whole oversized DAG is pinned with source user (permanent), as the page's pin add asks.""" + """The whole oversized DAG was pinned with source user (permanent), as the old page's pin add asked.""" helpers.run_display_page(self.victim, str(self.root)) self.assertNotIn(USER, self.victim.storage.pins().get(self.root, set())) + def test_allowlisted_bomb_cannot_store_more_than_max_size(self): + """Even a CID the operator allowlisted, which the page does fetch, is read only as far as MAX_SIZE + bytes go: the 1-byte-declared root passes the size check, and the bounded cat stores little.""" + declared = self.victim.stat(str(self.root))["size"] + self.assertEqual(declared, 1) # what the page's MAX_SIZE check sees + data_dir = os.path.join(self.tmp.name, "page-data") + os.makedirs(data_dir) + helpers.allow(data_dir, str(self.root)) + + output, calls = helpers.run_display_page(self.victim, str(self.root), data_dir=data_dir) + self.assertEqual(output, "x\n") # shown (Micron, allowlisted): its one byte + self.assertNotIn(["pin", "add", str(self.root)], calls) + stored = self.victim.storage.used_bytes() + self.assertLessEqual( + stored, MAX_SIZE + OVERHEAD, + f"an allowlisted view of a 1-byte-declared root stored {stored} bytes " + f"in {self.victim.storage.block_count()} blocks") + self.assertNotIn(self.root, self.victim.storage.pins()) + if __name__ == "__main__": unittest.main() From 9098381ec4654a799026b86cef01dd4e62d13a65 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:34:19 +1030 Subject: [PATCH 016/107] List only the operator's unsealed pins on the file-index-node page and escape every name --- .../file-index-node/README.md | 50 +++++++ .../file-index-node/index.mu | 127 ++++++++++++++++-- .../security/test_l10_index_page_injection.py | 82 ++++++++++- tests/security/test_l14_page_local_paths.py | 13 +- tests/security/test_m6_index_page_names.py | 63 ++++++++- 5 files changed, 319 insertions(+), 16 deletions(-) create mode 100644 companion-nomadnet-sites/file-index-node/README.md diff --git a/companion-nomadnet-sites/file-index-node/README.md b/companion-nomadnet-sites/file-index-node/README.md new file mode 100644 index 0000000..f095cc5 --- /dev/null +++ b/companion-nomadnet-sites/file-index-node/README.md @@ -0,0 +1,50 @@ +# file-index-node + +A NomadNet page that lists the files this node shares through RNFS, each with its name, an optional +description and its CID, so visitors can fetch them with `rnfs get `. + +## Install + +Copy `index.mu` into NomadNet's pages directory (usually `~/.nomadnetwork/storage/pages/`) and make it +executable (`chmod +x`). The page runs the `rnfs` CLI, so the rnfs daemon must be running as the same user as +NomadNet. Edit `DESCRIPTIONS`, `HIDDEN` and `SHOW_UNNAMED` at the top of `index.mu` to suit you. + +## What visitors can see + +Anyone who can reach your NomadNet node can open the page. It lists a pin only if all of these are true: + +- **You made it.** Its sources in `rnfs pin ls` include `user` (`rnfs add`, `rnfs pin add`, ...) or `folder` + (your shared folder). Pins that exist only because a contact sent you a file (`lxmf:`), a name you follow + updated (`follow`), or another node asked you to pin it (`remote:`) are never listed, so the page doesn't + reveal who sends you what. +- **It is complete** on this node. +- **It isn't sealed.** A sealed (encrypted) file's name is meant to stay private, so sealed files are left + off. The page recognises one by its first bytes. It reads them once per file with `rnfs cat --raw`, which + is a local read because the pin is complete, and it remembers the answer. If the check fails, the file is + left off and checked again on the next view. +- **It isn't in `HIDDEN`**, and it has a name or a description (or `SHOW_UNNAMED` is `True`). + +For each file the page shows only the last part of its local name (never the folders it was added from), +your description, and the CID. + +Names come from whoever made the content: for a folder, its entry names are chosen by its author. Every name +and description is escaped before it is printed. Newlines, control characters and bidi overrides become +U+FFFD, so a name can't add lines to the page. Backticks and backslashes are escaped, and a leading `>`, +`<`, `#` or `-` and `#!` can't turn into a heading, comment, divider or page header. A name shows exactly as +written, on one line. + +## The data directory + +The page keeps its files in `~/.local/state/rnfs-pages/file-index-node/`, which is outside NomadNet's pages +directory, so none of it is served. + +| File | What it is | +| --- | --- | +| `sealed.json` | Which pinned files are sealed, by CID. A CID's content never changes, so the answers stay valid. You can delete it at any time. | +| `page.log` | Errors, with details. Moved to `page.log.1` past 1 MB. | + +If the daemon can't be reached, visitors only see "The file list isn't available right now". The CLI's error +message, which can contain local paths, goes to `page.log`. + +`RNFS_PAGE_DIR` overrides the directory when you run the page by hand. NomadNet runs pages with a minimal +environment, so it doesn't pass that variable on. diff --git a/companion-nomadnet-sites/file-index-node/index.mu b/companion-nomadnet-sites/file-index-node/index.mu index 861104a..796f27e 100755 --- a/companion-nomadnet-sites/file-index-node/index.mu +++ b/companion-nomadnet-sites/file-index-node/index.mu @@ -1,9 +1,21 @@ #!/usr/bin/env python3 -"""NomadNet page listing the files this node has pinned (seeding) via RNFS, with optional descriptions.""" +"""NomadNet page listing the files this node has pinned (seeding) via RNFS, with optional descriptions. + +Anyone can open the page, so it lists only what the operator chose to share and nothing else about the node: +- only complete pins with source "user" or "folder" (pins made from contacts, follows or remote pin + requests are left off); +- never a sealed (encrypted) file, whose name is meant to stay private: the page checks each file pin's + first bytes once (`rnfs cat --raw`, a local read) and remembers the answer in its data dir; +- names and descriptions are escaped, so they show literally on one line and can't add Micron markup; +- visitors see a generic message if the list isn't available; the details go to a log in the data dir. +See this page's README. +""" import json import os import shutil import subprocess +import time +import unicodedata # Descriptions shown under each file, keyed by CID (a CID never changes, unlike a file name). DESCRIPTIONS = { @@ -21,21 +33,113 @@ HIDDEN = { # Pins with no name and no description (e.g. pinned before rnfs recorded names) are left off unless True. SHOW_UNNAMED = False +# Pin sources that mean the operator chose to share the file (not a contact, a follow or a remote pin). +OPERATOR_SOURCES = {"user", "folder"} + +# The page's own data dir, outside NomadNet's pages dir so none of it is served. NomadNet runs pages with a +# minimal environment, so RNFS_PAGE_DIR is only for running the page by hand (and tests). +PAGE_DIR = os.environ.get("RNFS_PAGE_DIR") or os.path.join( + os.path.expanduser("~"), ".local", "state", "rnfs-pages", "file-index-node") +SEALED_CACHE = os.path.join(PAGE_DIR, "sealed.json") # {cid: sealed?}, a CID's content never changes +LOG = os.path.join(PAGE_DIR, "page.log") +LOG_MAX = 1024 * 1024 # the log is moved to page.log.1 past this size + +# How a sealed (encrypted) file starts (rnfs/content/seal.py). +SEALED_MAGIC = b"rnfs-sealed\n" + # NomadNet may run this without ~/.local/bin on PATH. RNFS = shutil.which("rnfs") or os.path.expanduser("~/.local/bin/rnfs") +def printable(text): + """text as NomadNet will parse it, on one line: composed (NFC), without the combining marks and emoji + modifiers NomadNet drops before parsing a page (so none can hide markup behind it), and with every other + non-printable character (newlines, controls, bidi overrides) as U+FFFD.""" + text = unicodedata.normalize("NFC", str(text)) + return "".join(char if char.isprintable() else "\ufffd" for char in text + if not unicodedata.category(char).startswith("M") and not "\U0001f3fb" <= char <= "\U0001f3ff") + + def escape(text): - """Escape Micron's formatting characters so names and descriptions show literally.""" - return text.replace("\\", "\\\\").replace("`", "\\`") + """text made safe to print within a Micron line: shown literally, also at the start of a line. + + It is printable() (one line, nothing NomadNet drops); backslashes and backticks are escaped; "#!" can't + form a page header (NomadNet looks for "#!bg=" and "#!fg=" anywhere); a leading ">", "<", "#" or "-" (a + heading, depth reset, comment or divider) is escaped, and a leading escaped backtick gets a space before it + (NomadNet reads "\\`t" and "\\`{" at the start of a line as a table or partial). + """ + text = printable(text).replace("\\", "\\\\").replace("`", "\\`").replace("#!", "#\\!") + if text[:1] in (">", "<", "#", "-"): + text = "\\" + text + elif text.startswith("\\`"): + text = " " + text + return text + + +def log(message): + """Append a line to the page's log (never shown to visitors); a log that can't be written is skipped.""" + try: + os.makedirs(PAGE_DIR, mode=0o700, exist_ok=True) + if os.path.getsize(LOG) > LOG_MAX: + os.replace(LOG, LOG + ".1") + except OSError: + pass + try: + with open(LOG, "a", encoding="utf-8") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message!r}\n") + except OSError: + pass + + +def rnfs(*args): + """Run an rnfs command; its stdout bytes. Raises RuntimeError (with the CLI's error) if it fails.""" + result = subprocess.run([RNFS, *args], capture_output=True, timeout=20) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", "replace").strip() or f"rnfs {args[0]} failed") + return result.stdout def load_pins(): """This node's complete pins, from the running rnfs daemon.""" - result = subprocess.run([RNFS, "pin", "ls", "--json"], capture_output=True, text=True, timeout=20) - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "rnfs pin ls failed") - return [pin for pin in json.loads(result.stdout) if pin.get("complete")] + return [pin for pin in json.loads(rnfs("pin", "ls", "--json")) if pin.get("complete")] + + +_sealed = None # the sealed-check cache, loaded on first use + + +def is_sealed(pin): + """True if the pin is a sealed file, or if that can't be told (then it is left off, and checked again + next time). A complete pin is stored here, so reading its first bytes never fetches anything.""" + global _sealed + if pin.get("type") == "directory": + return False # rnfs seals single files only; a directory's entry names are in the clear anyway + if _sealed is None: + try: + with open(SEALED_CACHE, encoding="utf-8") as f: + _sealed = {cid: value for cid, value in json.load(f).items() if isinstance(value, bool)} + except (OSError, ValueError, AttributeError): + _sealed = {} + cid = pin["cid"] + if cid not in _sealed: + try: + _sealed[cid] = rnfs("cat", cid, "--raw", "--length", str(len(SEALED_MAGIC))).startswith(SEALED_MAGIC) + except Exception as error: + log(f"checking whether {cid} is sealed failed: {type(error).__name__}: {error}") + return True + try: + os.makedirs(PAGE_DIR, mode=0o700, exist_ok=True) + with open(SEALED_CACHE + ".tmp", "w", encoding="utf-8") as f: + json.dump(_sealed, f) + os.replace(SEALED_CACHE + ".tmp", SEALED_CACHE) + except OSError as error: + log(f"saving {SEALED_CACHE} failed: {error}") + return _sealed[cid] + + +def shown(pin): + """True if the pin may be listed: the operator's own (user or folder), not hidden, not sealed.""" + return (bool(OPERATOR_SOURCES & set(pin.get("sources") or ())) and pin["cid"] not in HIDDEN + and not is_sealed(pin)) def display_name(pin): @@ -49,12 +153,12 @@ def entry(pin): cid = pin["cid"] name = display_name(pin) description = DESCRIPTIONS.get(cid) - if cid in HIDDEN or (not name and not description and not SHOW_UNNAMED): + if (not name and not description and not SHOW_UNNAMED) or not shown(pin): return None lines = [f"`!{escape(name or '(unnamed)')}`!"] if description: lines.append(f" {escape(description)}") - lines.append(f" `F888{cid}`f") + lines.append(f" `F888{escape(cid)}`f") return "\n".join(lines) @@ -98,8 +202,9 @@ def section(title, pins): try: pins = load_pins() -except Exception as error: - print(f"The file list isn't available right now ({escape(str(error))}).") +except Exception as error: # the daemon isn't running, rnfs isn't installed, ... + log(f"listing pins failed: {type(error).__name__}: {error}") + print("The file list isn't available right now; try again later.") else: folders = [pin for pin in pins if pin.get("type") == "directory"] files = [pin for pin in pins if pin.get("type") != "directory"] diff --git a/tests/security/test_l10_index_page_injection.py b/tests/security/test_l10_index_page_injection.py index 7cdf77e..7568f90 100644 --- a/tests/security/test_l10_index_page_injection.py +++ b/tests/security/test_l10_index_page_injection.py @@ -6,8 +6,15 @@ A directory whose single entry name carries such a payload is built directly (decode accepts the name) and pinned, then rendered through the page's own entry(). The attacker needs a trusted route to get the directory pinned (an auto-fetch sender, a follow, or a remote pin); the injection itself is what is shown here. + +The fixed escape() (the same in both pages) makes every value one printable line with nothing Micron reads as +markup, even at the start of a line. The checks below follow NomadNet's parser (MicronParser.parse_line, +Browser's "#!bg="/"#!fg=" search and util.strip_modifiers); the archive's micron_check.py ran the same +payloads through NomadNet's own parser. """ +import re import tempfile +import unicodedata import unittest import RNS @@ -55,7 +62,6 @@ def pin_crafted_directory(self, entry_name: str) -> str: self.node.storage.pin(cid, USER) return str(cid) - @unittest.expectedFailure # L10: fails until escape() also strips newlines / control characters def test_entry_name_cannot_add_lines(self): """An entry name must render as a single page line, never inject a heading or a fake entry.""" self.pin_crafted_directory(f"readme.txt\n{INJECTED_HEADING}\n a second line") @@ -65,5 +71,79 @@ def test_entry_name_cannot_add_lines(self): self.assertNotIn(INJECTED_HEADING, printed_lines) +# Names and descriptions that try every kind of Micron markup: line-start commands, inline formatting, links, +# fields, page headers, literal blocks, partials and tables, control and bidi characters, and characters +# NomadNet drops before parsing (so they could hide markup behind them). +PAYLOADS = [">Official downloads", ">>sub", "", + "#!bg=f00", "a #!fg=000", "\\", "end\\", "a\nb", "a\r\n>b", "\x1b[31mred", "\u202eevil", + "line\u2028sep", "tab\there", "\u0301>combining", "\u0301`tl", "`\u0301=", "\U0001f3fb-", "\x00", + "e\u0301t\u00e9"] + + +def unescaped(text: str) -> str: + """text with Micron's escapes (a backslash and the character after it) removed.""" + return re.sub(r"\\.", "", text, flags=re.DOTALL) + + +def as_parsed(text: str) -> str: + """text as NomadNet parses it: without the combining marks, emoji modifiers and format characters that + util.strip_modifiers drops first.""" + return "".join(char for char in text if not unicodedata.category(char).startswith(("M", "Cf")) + and not "\U0001f3fb" <= char <= "\U0001f3ff") + + +class EscapeTest(unittest.TestCase): + """Both pages' escape() over every payload.""" + + def escapes(self): + """(page, escape) for the two pages.""" + index = helpers.index_page_namespace()["escape"] + with tempfile.TemporaryDirectory() as data_dir: + display = helpers.display_page_namespace(data_dir)["escape"] + return [("file-index-node", index), ("display-mu-file", display)] + + def test_escaped_values_are_one_printable_line(self): + """No newline, control, bidi or other non-printable character survives.""" + for page, escape in self.escapes(): + for payload in PAYLOADS: + with self.subTest(page=page, payload=payload): + text = escape(payload) + self.assertTrue(text.isprintable(), repr(text)) + self.assertEqual(len(text.splitlines()), 1, repr(text)) + + def test_escaped_values_have_no_markup_even_at_line_start(self): + """Every backtick is escaped, "#!" can't form a header, and no line-start command (>, <, #, -, a table, + a partial or a literal block) can begin the value, as NomadNet will parse it.""" + for page, escape in self.escapes(): + for payload in PAYLOADS: + with self.subTest(page=page, payload=payload): + text = escape(payload) + self.assertNotIn("`", unescaped(text), repr(text)) + self.assertNotIn("#!", text) + parsed = as_parsed(text) + self.assertFalse(parsed[:1] in (">", "<", "#", "-", "`"), repr(parsed)) + self.assertFalse(parsed.startswith("\\`"), repr(parsed)) # read as `t / `{ at line start + + def test_ordinary_names_are_unchanged(self): + """Plain names keep their text (an accent composed, not dropped).""" + for page, escape in self.escapes(): + with self.subTest(page=page): + self.assertEqual(escape("Alpine Linux 3.24.2 ISOs (x86_64).iso"), + "Alpine Linux 3.24.2 ISOs (x86_64).iso") + self.assertEqual(escape("cafe\u0301 menu"), "caf\u00e9 menu") + + def test_whole_entry_stays_on_its_lines(self): + """A directory pin named with every payload at once (no path separators, so display_name keeps it + whole) still renders as exactly the page's own two lines: the bold name, then the CID.""" + index = helpers.index_page_namespace() + pin = {"cid": "bafkreiailvfecuuvt6tctx3owax2jic7y5af2ndriwo6xymcq2uwyut34a", "sources": ["user"], + "complete": True, "type": "directory", "name": "\n".join(PAYLOADS).replace("/", "")} + lines = index["entry"](pin).split("\n") + self.assertEqual(len(lines), 2) + self.assertTrue(lines[0].startswith("`!") and lines[0].endswith("`!")) + self.assertNotIn("`", unescaped(lines[0][2:-2])) + + if __name__ == "__main__": unittest.main() diff --git a/tests/security/test_l14_page_local_paths.py b/tests/security/test_l14_page_local_paths.py index 3c9055b..44b3a5a 100644 --- a/tests/security/test_l14_page_local_paths.py +++ b/tests/security/test_l14_page_local_paths.py @@ -4,7 +4,7 @@ This runs the page's real script with a CLI that fails exactly as rnfs does when the daemon is down, and checks the visitor is not shown the local path the error carries. The fixed pages show a generic message and -write the detail to page.log in their data dir; the tests check that too. +write the detail to page.log in their data dir; the tests check that too, for both pages. """ import os import tempfile @@ -69,6 +69,17 @@ def run(argv, *args, **kwargs): self.assertIn("can't be shown right now", output) self.assertIn(missing, self.log()) + def test_index_page_hides_cli_stderr_paths(self): + """The file-index-node page, with the daemon down, shows a generic message and logs the detail.""" + def run(argv, *args, **kwargs): + """Fail as the CLI does when the daemon is down.""" + return helpers._Completed(1, stderr=DAEMON_DOWN) + + output = helpers.exec_index_page(run, self.data_dir) + self.assertNotIn(RNFS_DIR, output) + self.assertIn("The file list isn't available right now", output) + self.assertIn(DAEMON_DOWN, self.log()) + def test_log_is_kept_small(self): """Past LOG_MAX bytes the log moves to page.log.1, so visitors causing errors can't fill the disk.""" log_max = helpers.display_page_namespace(self.data_dir)["LOG_MAX"] diff --git a/tests/security/test_m6_index_page_names.py b/tests/security/test_m6_index_page_names.py index 75a8263..d9962d7 100644 --- a/tests/security/test_m6_index_page_names.py +++ b/tests/security/test_m6_index_page_names.py @@ -5,12 +5,17 @@ The page's own entry() is run over the real pin_ls() of an offline node (JSON round-tripped, as the page does). The recheck notes SHOW_UNNAMED=False hides unnamed file pins, so the assertions are precise: they -target sealed-file names and a named directory pin from a non-operator source. +target sealed-file names and a named directory pin from a non-operator source. The fixed page lists only +user/folder pins and skips sealed files, which it tells by their first bytes (`rnfs cat --raw`, a local read +of a complete pin), remembering the answer per CID in its data dir. """ import io +import json import os +import subprocess import tempfile import unittest +from unittest import mock import RNS @@ -46,7 +51,6 @@ def page(self) -> str: """The page body the file-index-node script would print for this node's pins.""" return "\n".join(line for line in helpers.render_index(self.node) if line) - @unittest.expectedFailure # M6: fails until the page omits (or redacts) the names of sealed files def test_sealed_file_names_are_not_published(self): """A sealed file's plaintext name must never reach the page.""" path = os.path.join(self.tmp.name, "Q3-layoffs.xlsx") @@ -58,7 +62,6 @@ def test_sealed_file_names_are_not_published(self): self.assertNotIn("Q3-layoffs.xlsx", page) self.assertNotIn("board-minutes.txt", page) - @unittest.expectedFailure # M6: fails until the page lists only operator (user/folder) pins def test_pins_from_contacts_are_not_published(self): """A directory pinned from a contact (a remote: source), not by the operator, must not be listed.""" folder = os.path.join(self.tmp.name, "from-a-contact") @@ -69,6 +72,60 @@ def test_pins_from_contacts_are_not_published(self): self.node.storage.pin(Cid.parse(cid), "remote:" + "ab" * 16) # a contact asked us to pin it self.assertNotIn("offer-letters", self.page()) + def add_file(self, name, content=b"shared data", **kwargs): + """Add a file named name (user pin); its CID.""" + path = os.path.join(self.tmp.name, name) + with open(path, "wb") as f: + f.write(content) + return self.node.add_path(path, **kwargs)["cid"] + + def test_operator_files_are_still_listed(self): + """Plain user pins, and a pin the operator also holds for a contact, are listed by name.""" + self.add_file("field-guide.pdf") + folder = os.path.join(self.tmp.name, "maps") + os.makedirs(folder) + with open(os.path.join(folder, "north.png"), "wb") as f: + f.write(b"png") + cid = self.node.add_path(folder, wrap=True)["cid"] + self.node.storage.pin(Cid.parse(cid), "remote:" + "cd" * 16) # a contact asked for it too + page = self.page() + self.assertIn("field-guide.pdf", page) + self.assertIn("maps", page) + + def test_sealed_check_is_remembered(self): + """Each file pin is checked once (one raw cat of its first bytes); the answer is kept in the data dir.""" + self.add_file("field-guide.pdf") + self.add_file("Q3-layoffs.xlsx", b"secret", to_self=True) + data_dir = os.path.join(self.tmp.name, "page-data") + calls = [] + helpers.render_index(self.node, calls, data_dir) + self.assertEqual(sorted(call[0] for call in calls), ["cat", "cat"]) + self.assertTrue(all(call[2:] == ["--raw", "--length", "12"] for call in calls)) + with open(os.path.join(data_dir, "sealed.json"), encoding="utf-8") as f: + self.assertEqual(sorted(json.load(f).values()), [False, True]) + calls.clear() + page = "\n".join(line for line in helpers.render_index(self.node, calls, data_dir) if line) + self.assertEqual(calls, []) + self.assertIn("field-guide.pdf", page) + self.assertNotIn("Q3-layoffs.xlsx", page) + + def test_a_file_that_cannot_be_checked_is_left_off(self): + """If the sealed check fails (here the CLI errors), the file is left off and the reason logged.""" + self.add_file("field-guide.pdf") + data_dir = os.path.join(self.tmp.name, "page-data") + pin = json.loads(json.dumps(self.node.pin_ls()))[0] + + def run(argv, *args, **kwargs): + """Fail as the CLI does when the daemon is down.""" + return helpers._Completed(1, stderr="rnfs: can't reach the daemon") + + with helpers.page_dir(data_dir), mock.patch.object(subprocess, "run", run): + namespace = helpers.index_page_namespace() + self.assertIsNone(namespace["entry"](pin)) + with open(os.path.join(data_dir, "page.log"), encoding="utf-8") as f: + self.assertIn("can't reach the daemon", f.read()) + self.assertFalse(os.path.exists(os.path.join(data_dir, "sealed.json"))) # asked again next time + if __name__ == "__main__": unittest.main() From 7f8cbdaf620b58c36a2d63f0aeba110136836b7b Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:38:41 +1030 Subject: [PATCH 017/107] Update the gateway tests for the removed CORS rule, fixed error messages and archive HEADs --- tests/test_gateway.py | 28 +++++++++++----------------- tests/test_gateway_formats.py | 26 ++++++++++++++------------ 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index dc0629d..e82a0c4 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -86,20 +86,15 @@ def test_host_check(self): self.assertEqual(self.get(f"/rnfs/{self.text}", host=host)[0], 403, host) self.assertEqual(self.node.called("info"), []) - def test_cors_only_for_own_referer(self): + def test_no_cors_headers(self): + # Not even for a gateway Referer (which sandboxed pages never send) or Origin: null (which every site's + # sandboxed frames send): no web page may read gateway responses path = f"/rnfs/{self.text}" - for referer in (f"http://127.0.0.1:{self.port}/rnfs/{self.root}/page.html", f"http://localhost:{self.port}/"): + for headers in ({"Referer": f"http://127.0.0.1:{self.port}/rnfs/{self.root}/page.html"}, + {"Referer": f"http://localhost:{self.port}/"}, {"Origin": "null"}, {}): for method in ("GET", "HEAD"): - headers = self.get(path, {"Referer": referer}, method=method)[1] - self.assertEqual(headers["Access-Control-Allow-Origin"], "*", referer) - # Errors too, so a page can tell a missing file from a blocked one - self.assertEqual(self.get(f"/rnfs/{self.root}/missing", {"Referer": referer})[1] - ["Access-Control-Allow-Origin"], "*") - for referer in (None, "http://evil.example/", f"http://127.0.0.1:{self.port + 1}/", - f"https://127.0.0.1:{self.port}/", f"http://127.0.0.1:{self.port}.evil.example/", "garbage"): - headers = self.get(path, {"Referer": referer} if referer else {})[1] - self.assertNotIn("Access-Control-Allow-Origin", headers, referer) - self.assertEqual(self.get(path)[1]["Vary"], "Referer") + self.assertNotIn("Access-Control-Allow-Origin", self.get(path, headers, method=method)[1], headers) + self.assertNotIn("Access-Control-Allow-Origin", self.get(f"/rnfs/{self.root}/missing", headers)[1]) def test_scripts_off(self): self.config.gateway_scripts = False @@ -205,16 +200,15 @@ def test_errors(self): self.assertNotIn("ETag", headers) def test_500_body_names_no_local_path(self): - # A gateway page may read error responses (the CORS rule): the detail goes only to the log + # A node error's text may name local paths: the detail goes only to the log error = OSError(28, "No space left on device", "/home/alice/.rnfs/blocks/abc") - referer = {"Referer": f"http://127.0.0.1:{self.port}/rnfs/{self.root}/"} with mock.patch.object(self.node, "info", side_effect=error), mock.patch("rnfs.gateway.log") as log: - status, headers, body = self.get(f"/rnfs/{self.text}", referer) - self.assertEqual((status, headers["Access-Control-Allow-Origin"]), (500, "*")) + status, headers, body = self.get(f"/rnfs/{self.text}") + self.assertEqual(status, 500) self.assertNotIn(b"alice", body) self.assertIn("No space left on device", log.call_args.args[0]) with mock.patch.object(self.node, "info", side_effect=ValueError("bad target")): - self.assertEqual(self.get(f"/rnfs/{self.text}")[2], b"bad target\n") # other errors still explain + self.assertEqual(self.get(f"/rnfs/{self.text}")[2], b"invalid request\n") # a fixed message per status def test_http_0_9_gets_headers(self): # A bare body for an HTTP/0.9 request would have no CSP: it is refused, with the usual headers diff --git a/tests/test_gateway_formats.py b/tests/test_gateway_formats.py index a32685c..5a04040 100644 --- a/tests/test_gateway_formats.py +++ b/tests/test_gateway_formats.py @@ -101,9 +101,8 @@ def test_archive_of_a_path_and_head(self): self.assertEqual(headers["ETag"], f'"{target}.archive"') other = Storage(os.path.join(self.tmp.name, "o", "blocks"), os.path.join(self.tmp.name, "o", "state"), 10**9) self.assertEqual([str(c) for c in archive.import_archive(io.BytesIO(body), other)["roots"]], [target]) - status, head_headers, head_body = self.get(f"/rnfs/{self.root}/sub?format=archive", method="HEAD") - self.assertEqual((status, head_body), (200, b"")) - self.assertEqual(head_headers["Content-Length"], headers["Content-Length"]) + # A HEAD never gets an archive, stored or not (L6) + self.assertEqual(self.get(f"/rnfs/{self.root}/sub?format=archive", method="HEAD")[0], 409) self.assertEqual(self.get(f"/rnfs/{self.root}/sub?format=archive", {"If-None-Match": f'"{target}.archive"'})[0], 304) @@ -117,12 +116,12 @@ def test_archive_limits(self): self.assertEqual(status, 413) # max_output_entries self.assert_common(headers) - def test_cors_rule(self): + def test_no_cors_headers(self): own = {"Referer": f"http://127.0.0.1:{self.server.port}/rnfs/{self.root}/"} for fmt in ("raw", "archive"): - self.assertEqual(self.get(f"/rnfs/{self.root}?format={fmt}", own)[1]["Access-Control-Allow-Origin"], "*") - self.assertIsNone(self.get(f"/rnfs/{self.root}?format={fmt}", - {"Referer": "http://example.com/"})[1]["Access-Control-Allow-Origin"]) + for headers in (own, {"Origin": "null"}, {"Referer": "http://example.com/"}): + self.assertIsNone(self.get(f"/rnfs/{self.root}?format={fmt}", headers)[1] + ["Access-Control-Allow-Origin"], headers) def test_gateway_fetches_limit(self): missing = str(Cid.for_block(RAW, b"not stored anywhere")) @@ -138,8 +137,9 @@ def test_gateway_fetches_limit(self): limiter.release() def test_archives_of_unstored_content_need_a_users_get(self): - """A web page's request or a HEAD gets an archive only of content stored completely: anything else - would fetch a whole DAG for nobody (409, with the common headers); the user's own GET still fetches.""" + """A web page's request or a HEAD never gets an archive: it would fetch a whole DAG for nobody, and a 200 + for stored content only would tell pages what is stored (409, with the common headers); the user's own + GET still fetches.""" missing = str(Cid.for_block(RAW, b"not stored anywhere")) calls = [] real = self.node.export_stream @@ -151,7 +151,7 @@ def test_archives_of_unstored_content_need_a_users_get(self): self.assert_common(response) self.assertEqual(calls, []) self.assertIn(b"stored", self.get(f"/rnfs/{missing}?format=archive", {"Sec-Fetch-Site": "cross-site"})[2]) - # The user's own GET tries the fetch; stored content is served to anyone, HEAD too + # The user's own GET tries the fetch; stored content gets the same 409s, and the user's GET def not_found(*args, **kwargs): """The fetch finds no provider (this node has no network).""" calls.append(args) @@ -161,8 +161,10 @@ def not_found(*args, **kwargs): self.assertEqual(self.get(f"/rnfs/{missing}?format=archive", {"Sec-Fetch-Site": "none"})[0], 404) self.assertEqual(len(calls), 1) self.node.export_stream = real - for headers, method in (({"Sec-Fetch-Site": "cross-site"}, "GET"), ({"Sec-Fetch-Site": "cross-site"}, "HEAD")): - self.assertEqual(self.get(f"/rnfs/{self.root}?format=archive", headers, method)[0], 200) + for headers, method in (({"Sec-Fetch-Site": "cross-site"}, "GET"), ({"Sec-Fetch-Site": "cross-site"}, "HEAD"), + ({}, "HEAD")): + self.assertEqual(self.get(f"/rnfs/{self.root}?format=archive", headers, method)[0], 409, (headers, method)) + self.assertEqual(self.get(f"/rnfs/{self.root}?format=archive", {"Sec-Fetch-Site": "none"})[0], 200) def test_archive_fetch_is_cancelled_when_the_client_leaves(self): """The fetch behind a user's archive GET runs under an observer that cancels it once the client is gone.""" From 4a765d4ea27437dad11b2b5294323d697a4b1b3c Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:39:07 +1030 Subject: [PATCH 018/107] Document the gateway_fetches limit as an accepted storage leak and test the documented behaviour --- specification.md | 4 +++- tests/security/test_l6_gateway_storage_oracle.py | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/specification.md b/specification.md index 68a2e41..62b5ec7 100644 --- a/specification.md +++ b/specification.md @@ -904,7 +904,9 @@ gateway can never reach the API. limit. Browsers send `Sec-Fetch-Site` to `127.0.0.1` and `localhost`, and `none` only for a navigation the user started (address bar, bookmark); a request without it comes from a local tool the user runs. - Apart from timing, the `gateway_fetches` limit and content no provider has, a web page's request gets the - same answer whether the node stores the content or has to fetch it. + same answer whether the node stores the content or has to fetch it. The limit is a known, accepted leak: while a + page keeps every slot busy with fetches, stored content is still served and content that needs a fetch gets 503, + so the page can tell them apart. Like the timing difference, closing it would cost more than it protects. - The gateway never expands contact nicks (`/name//` is not supported), so web pages can't use or probe the address book. diff --git a/tests/security/test_l6_gateway_storage_oracle.py b/tests/security/test_l6_gateway_storage_oracle.py index 493a514..65ce81c 100644 --- a/tests/security/test_l6_gateway_storage_oracle.py +++ b/tests/security/test_l6_gateway_storage_oracle.py @@ -143,17 +143,17 @@ def test_users_own_archive_get_still_works(self): self.assertEqual((status, body), (200, f"archive of {self.site}".encode())) self.assertGreater(self.gateways["unstored"][0].fetches, fetches) - # L6 (residual): fails until a web page's request for stored content is answered as one for content not stored - # while every gateway_fetches slot is busy (today stored content is served without a slot: 200 against 503) - @unittest.expectedFailure - def test_full_fetch_slots_dont_reveal_storage(self): + def test_full_fetch_slots_still_reveal_storage(self): + """Accepted residual (specification.md, gateway section): while a page holds every gateway_fetches slot, + stored content is still served and content that needs a fetch gets 503. Closing it would break pages or + move the leak into timing; this test pins the documented behaviour so a change to it is noticed.""" for node, server in self.gateways.values(): limiter = server.server.RequestHandlerClass.limiter for _ in range(limiter.limit): # a page holds every slot with fetches that never finish self.assertTrue(limiter.try_acquire()) self.addCleanup(lambda limiter=limiter: [limiter.release() for _ in range(limiter.limit)]) - self.assert_same("GET", f"/rnfs/{self.data}", PAGE_REQUESTS["cross-site image"], "slots full") - + responses = self.both("GET", f"/rnfs/{self.data}", PAGE_REQUESTS["cross-site image"]) + self.assertEqual((responses["stored"][0], responses["unstored"][0]), (200, 503)) if __name__ == "__main__": unittest.main() From 424b20b287416048e32b96a881f14d9f7e5dec1a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:39:28 +1030 Subject: [PATCH 019/107] Search past alpha for a name's keepers, rank them by routing-table standing and ask further batches --- CHANGELOG.md | 8 ++ design.md | 13 ++- rnfs/dht/lookup.py | 10 ++ rnfs/dht/routing.py | 18 ++++ rnfs/features/keepers.py | 109 ++++++++++++++++--- rnfs/fetcher.py | 4 +- specification.md | 21 +++- tests/security/test_m3_keeper_discovery.py | 117 +++++++++++++++++++-- 8 files changed, 263 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2344178..68fc754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ DHT and names: own records are always accepted (they were refused once the store was full). A publisher's share of the store grows with age: a tenth of it until it has been seen for `provider_seniority`. First-seen times are kept for a week after a publisher's last record goes, so one that was away for a while isn't new again. +- Resolving through keepers no longer asks only the first `alpha` keepers found, oldest record first: a few + identities that stored provider records for a name's key before its real keepers (and, on a slow node, the + departed owner's own record) could hide every keeper. Keepers recorded locally no longer skip the DHT search, + which now looks for 3 × `alpha` keepers listed by more than one node; they are ranked by routing-table + seniority (live servers first, then those that failed or went silent, then unknown IDs), at random within + each group, and asked a batch at a time. Another batch is asked while no answer reaches the sequence already + known or only one keeper answered, at most 3 batches (2 on slow nodes, where a second `/name` is spent only + if there's no known sequence to check the first answer against). - A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own client never sends more. diff --git a/design.md b/design.md index 1783cb1..5364fb2 100644 --- a/design.md +++ b/design.md @@ -148,7 +148,9 @@ packaging/ requirements.lock (hash-pinned runtime dependencies), upda `routing.py` — `RoutingTable(own_id, config, clock=time.monotonic)`: `heard(node_id) -> bool` (proven server: announce in server mode, or answered `/find`), `failed(node_id) -> bool` (True if dropped), `remove(node_id)`, `ban(node_id)`, `is_banned(node_id)`, `closest(key, count=K, for_reply=False)`, -`seniority(node_id) -> float | None` (seconds in the table), `contains(node_id)`, `node_count()`. +`seniority(node_id) -> float | None` (seconds in the table), `is_senior(node_id, min_age)`, `standing(node_id, +min_age) -> int` (0 senior and healthy, 1 healthy, 2 stale, 3 absent: ranks keepers), `contains(node_id)`, +`node_count()`. `providers.py` — `ProviderStore(max_records, state_path, ttl=PROVIDER_TTL, clock=time.time, rng=random, own_id=None, seniority=0)`: `store(key, publisher) -> bool`, `withdraw(key, publisher)`, @@ -167,7 +169,8 @@ from a heap of (first seen, arrival) with stale entries dropped lazily. First-se `Lookup(key, start_nodes, query, own_id, alpha, beta, k, timeout, is_known, on_answer, on_failure)` with `find_closest() -> list[bytes]` and `find_providers(on_provider, should_stop) -> list[bytes]`. `is_known(id)` says whether RNS knows its identity; at most `alpha` unknown IDs are tried per lookup (`allow_unknown(id)`, also -used by the fetcher for provider IDs). +used by the fetcher for provider IDs). `listing_count()` is how many nodes have answered with at least one +provider, so the keeper search can wait for providers listed by more than one source. ## Network layer interfaces @@ -490,7 +493,11 @@ Everything in the specification is implemented, with these simplifications and a sends nothing for a `None` response, so "not kept" is `[]`. Kept records are offered to `NameManager` at startup and on every resolve (the rollback floor). Kept names without a record are retried round robin, at most 64 per `announce_interval`, continuing after the last one asked. `_store` refuses a record below the - sequence `NameManager` holds for the name. + sequence `NameManager` holds for the name. `ask_keepers` searches for 3 × alpha candidates from two sources + (`Lookup.listing_count`, plus one for local records), ranks them with `RoutingTable.standing` and a random + tie-break (`rank_keepers`), and asks batches of alpha until an answer reaches the highest known sequence + (`NameManager.record` or the kept record) from two keepers, at most `keeper_rounds()` batches (3; 2 on slow + nodes, where one current answer is enough once a sequence is known). - **Names.** A key imported, or found without an entry in `names.json`, is marked `imported`: its first publish offers kept records, makes a path request and queries keepers before choosing a sequence. The pins of name keys deleted by hand are released by `release_gone()`, once the features are set up. diff --git a/rnfs/dht/lookup.py b/rnfs/dht/lookup.py index 7f5d3d8..f17ab6b 100644 --- a/rnfs/dht/lookup.py +++ b/rnfs/dht/lookup.py @@ -85,6 +85,7 @@ def __init__(self, key: bytes, start_nodes: Iterable[bytes], query: FindQuery, o self._allowed_unknown: set[bytes] = set() # unknown IDs that were given a path request self._refused_unknown: set[bytes] = set() # unknown IDs over the budget self._reported: set[bytes] = set() # providers already passed to on_provider, in any run + self._listing: set[bytes] = set() # nodes whose reply listed a provider, in any run self._last_known: dict[bytes, int] = {} # the nodes the last run knew of and hadn't seen fail self._last_queried: set[bytes] = set() # the nodes the last run queried self._wake: queue.Queue | None = None @@ -145,6 +146,12 @@ def remaining(self) -> float: return self.timeout return max(0.0, self.deadline - self.clock()) + def listing_count(self) -> int: + """How many nodes have answered with at least one provider (in any run), so a caller can tell + providers vouched for by several nodes from those one node listed.""" + with self._lock: + return len(self._listing) + def closest_answered(self) -> list[bytes]: """The k closest nodes that answered in any run so far, closest first.""" with self._lock: @@ -237,6 +244,9 @@ def _iterate(self, results: queue.Queue, on_provider: NodeCallback | None, with self._lock: self._answered.add(node_id) providers, nodes = reply + if providers: + with self._lock: + self._listing.add(node_id) for new_id in nodes: if new_id not in known and new_id not in queried and self._usable(new_id): known[new_id] = self._distance(new_id) diff --git a/rnfs/dht/routing.py b/rnfs/dht/routing.py index eb86f9f..4a85c10 100644 --- a/rnfs/dht/routing.py +++ b/rnfs/dht/routing.py @@ -185,6 +185,24 @@ def seniority(self, node_id: bytes) -> float | None: entry = self._find(node_id) return None if entry is None else self.clock() - entry.first_seen + def is_senior(self, node_id: bytes, min_age: float) -> bool: + """True if node_id has been in the table (kept across a short absence) for at least min_age seconds.""" + known_for = self.seniority(node_id) + return known_for is not None and known_for >= min_age + + def standing(self, node_id: bytes, min_age: float) -> int: + """How much node_id is trusted to be a live, long-standing server, best first: 0 in the table for at + least min_age and healthy, 1 in the table and healthy, 2 in the table but failed a query since last heard + or silent over 2 × announce_interval, 3 not in the table. Ranks keepers (section 9).""" + with self._lock: + entry = self._find(node_id) + if entry is None: + return 3 + now = self.clock() + if self._stale(entry, now): + return 2 + return 0 if now - entry.first_seen >= min_age else 1 + def contains(self, node_id: bytes) -> bool: """True if node_id is in the table.""" with self._lock: diff --git a/rnfs/features/keepers.py b/rnfs/features/keepers.py index 42d5d8c..c73d34b 100644 --- a/rnfs/features/keepers.py +++ b/rnfs/features/keepers.py @@ -15,8 +15,11 @@ a CID (batched /provide, republish, withdrawal); the prefix keeps it apart from CID keys (SHA-256 of a 36- or 39-byte binary CID). A name is advertised only once a record of it is held. - **Resolving** (NameManager.resolve): when the path request heard nothing (or always, with keeper_query = - "always"), find providers of the name's key and ask up to alpha of them with /name; only verifying records - are offered to the usual sequence rules (persisted highest sequence, no rollback). + "always"), find providers of the name's key (past the first alpha, with local records only candidates), rank + them by routing-table standing then at random, and ask them with /name a batch of alpha at a time, asking + another batch (up to a bound) while no answer reaches the sequence already known or only one keeper + answered; only verifying records are offered to the usual sequence rules (persisted highest sequence, no + rollback). - **What is kept.** Names kept by the user (`rnfs name keep`), followed names (rnfs.features.follow), and this node's own published names, which it signs itself. At most keep_max_names, own names not counted. - **Refreshing.** On hearing an announce with a higher sequence for a kept name, the signed announce is read @@ -32,6 +35,7 @@ """ import hashlib import os +import random import threading import time from dataclasses import dataclass @@ -62,6 +66,8 @@ RNS_CACHE_TRIES = 4 # a shared instance forwards an announce to its clients before caching it RNS_CACHE_WAIT = 0.5 LOOP_INTERVAL = 60.0 # the background thread syncs own names and saves at least this often +KEEPER_CANDIDATES = 3 # the search for a name's keepers runs until 3 × alpha are known (from 2+ sources) +KEEPER_ROUNDS = 3 # batches of alpha keepers one query asks at most (2 on slow nodes) REQUEST_ID_LENGTH = 16 @@ -379,6 +385,7 @@ def __init__(self, node): # The last name the periodic retry queued: the next retry starts after it, so past MAX_PENDING # names without a record every one is asked in turn self._retry_cursor: bytes | None = None + self._rng = random.Random() # breaks ties between keepers of equal standing node.names.keepers = self node.publisher.add_key_source(self.store.provided_keys) # Kept records are the floor for resolving: NameManager persists only names resolved by the user, @@ -629,25 +636,44 @@ def query(self, name: bytes) -> list[tuple[int, Cid, float]]: return [(record.sequence, record.cid, min(float(record.emitted), now)) for record in records] def ask_keepers(self, name: bytes) -> list[SignedRecord]: - """Find up to alpha keepers of name in the DHT and ask each with /name; the verifying records.""" + """Find keepers of name in the DHT and ask them with /name, a batch of alpha at a time; the verifying + records. + + Keepers recorded here are candidates, never a reason to skip the search, which runs until + KEEPER_CANDIDATES × alpha are known and more than one source listed some (this node's records count as + one), or the lookup is complete: identities that stored records for the name's key first can't take + every slot. Candidates are asked best standing first (rank_keepers). Another batch is asked, up to + keeper_rounds() in all, while no answer reaches the highest sequence already known for the name (from + an announce, a resolve or a kept record), or fewer than two keepers answered (on a slow node: only + while nothing is known to compare against). Each verifying record is returned; the caller keeps the + highest sequence. + """ node = self.node key = name_key(name) alpha = node.config.effective_alpha(node.slow) - found = [p for p in node.records.providers(key, exclude=node.routing.is_banned) if p != node.node_id] + banned = node.routing.is_banned + local = [p for p in node.records.providers(key, exclude=banned) if p != node.node_id] + found = dict.fromkeys(local) lookup = node._lookup(key) + wanted = KEEPER_CANDIDATES * alpha def add(provider: bytes): """Collect a provider the lookup reports.""" - if provider != node.node_id and provider not in found and not node.routing.is_banned(provider): - found.append(provider) + if provider != node.node_id and provider not in found and not banned(provider): + found[provider] = None + + def enough() -> bool: + """Enough candidates, listed by more than one source.""" + return len(found) >= wanted and lookup.listing_count() + (1 if local else 0) >= 2 - if len(found) < alpha: - lookup.find_providers(add, should_stop=lambda: len(found) >= alpha) - chosen = [p for p in found if node._is_known(p) or lookup.allow_unknown(p)][:alpha] - if not chosen: + lookup.find_providers(add, should_stop=enough) + candidates = self.rank_keepers(list(found)) + if not candidates: _log(f"no keepers found for name {name.hex()}") return [] + floor = self._known_sequence(name) results: list[SignedRecord] = [] + answered: set[bytes] = set() lock = threading.Lock() def ask(provider: bytes): @@ -661,18 +687,67 @@ def ask(provider: bytes): return with lock: results.append(record) + answered.add(provider) - threads = [threading.Thread(target=ask, args=(provider,), name="rnfs-keeper-query", daemon=True) - for provider in chosen] - for thread in threads: - thread.start() deadline = time.monotonic() + max(1.0, lookup.remaining()) - for thread in threads: - thread.join(max(0.0, deadline - time.monotonic())) + asked = 0 + for _ in range(self.keeper_rounds()): + batch = [] + while candidates and len(batch) < alpha: + provider = candidates.pop(0) + if node._is_known(provider) or lookup.allow_unknown(provider): + batch.append(provider) + if not batch: + break + asked += len(batch) + threads = [threading.Thread(target=ask, args=(provider,), name="rnfs-keeper-query", daemon=True) + for provider in batch] + for thread in threads: + thread.start() + for thread in threads: + thread.join(max(0.0, deadline - time.monotonic())) + with lock: + best = max((record.sequence for record in results), default=None) + responders = len(answered) + current = best is not None and (floor is None or best >= floor) + # A second keeper's answer is only worth its airtime on a slow node if there's nothing to check + # the first against + confirmed = responders >= 2 or (node.slow and floor is not None) + if (current and confirmed) or time.monotonic() >= deadline: + break with lock: - _log(f"asked {len(chosen)} keepers of name {name.hex()}: {len(results)} verifying records") + _log(f"asked {asked} of {asked + len(candidates)} keepers of name {name.hex()}: " + f"{len(results)} verifying records") return list(results) + def keeper_rounds(self) -> int: + """How many batches of alpha keepers one query asks at most: KEEPER_ROUNDS, or 2 on a slow node, where + alpha is 1 and each /name costs a link and two packets of airtime.""" + return 2 if self.node.slow else KEEPER_ROUNDS + + def rank_keepers(self, candidates: list[bytes]) -> list[bytes]: + """candidates in the order to ask them: by routing-table standing (RoutingTable.standing: servers known + for provider_seniority and healthy first, then healthy ones, then those that failed a query or went + silent, such as an owner that has left, then IDs not in the table), then at random, as the fetcher + ranks providers by seniority first. Recording a provider record is free, so neither its age nor the + order replies list it in decides.""" + routing = self.node.routing + seniority = self.node.config.provider_seniority + ranked = [(routing.standing(provider, seniority), self._rng.random(), provider) for provider in candidates] + ranked.sort() + return [provider for _, _, provider in ranked] + + def _known_sequence(self, name: bytes) -> int | None: + """The highest sequence this node knows for name (record held by NameManager, or kept), or None.""" + known = [] + record = self.node.names.record(name) + if record is not None: + known.append(record.sequence) + kept = self.store.get(name) + if kept is not None: + known.append(kept.sequence) + return max(known, default=None) + # Serving def requests(self) -> dict: diff --git a/rnfs/fetcher.py b/rnfs/fetcher.py index ee54be4..0facb15 100644 --- a/rnfs/fetcher.py +++ b/rnfs/fetcher.py @@ -642,8 +642,8 @@ def _usable(self) -> list[_Provider]: def _rank(self, node_id: bytes, path: mesh.PathInfo, order: int) -> tuple: """Sort key for a provider: seniority stays first (an attacker's fresh identities can't jump the queue by being near), then hops and next-hop bitrate, then order.""" - known_for = self.fetcher.routing.seniority(node_id) - return mesh.provider_rank(known_for is not None and known_for >= self.config.provider_seniority, path, order) + senior = self.fetcher.routing.is_senior(node_id, self.config.provider_seniority) + return mesh.provider_rank(senior, path, order) def _path(self, node_id: bytes) -> mesh.PathInfo: """What RNS knows about the path to node_id (nothing if the lookup fails).""" diff --git a/specification.md b/specification.md index 1dfca7c..7d87349 100644 --- a/specification.md +++ b/specification.md @@ -648,10 +648,23 @@ the name through the network and asks keepers (below), since the key's earlier r - To refresh, call `Transport.request_path(name)`, wait up to `path_timeout`, then read `Identity.recall_app_data(name)`. - If the path request heard nothing within `path_timeout` (or always, with `keeper_query = always`), ask - keepers (below): find providers of the name's key (section 6, stopping once `alpha` are known), ask up to - `alpha` of them with `/name`, and offer every verifying record to the sequence rules. A keeper query (a DHT - lookup plus up to `alpha` requests) costs roughly ten times the airtime of a resolve a path response - answers, so `always` SHOULD NOT be used on slow links. + keepers (below). Storing a provider record is free, so neither a record's age nor the order a reply lists it + in may decide which keepers are asked: + - Find providers of the name's key (section 6). Providers recorded locally are candidates, never a reason + to skip the search, which runs until 3 × `alpha` are known and more than one source listed some (this + node's own records count as one source), or until the lookup is complete. + - Rank the candidates by routing-table seniority, as providers are (section 6): servers in the routing table + for over `provider_seniority` that haven't failed a query since last heard nor been silent for + 2 × `announce_interval` first, then other such servers, then servers that failed a query or went silent + (an owner that has left), then IDs not in the table; at random within each group. + - Ask them with `/name`, a batch of `alpha` at a time, and offer every verifying record to the sequence + rules, so the highest valid sequence wins. Ask another batch while no answer reaches the highest sequence + already known for the name (from an announce, an earlier resolve or a kept record), or while fewer than two + keepers have answered; at most 3 batches. A slow node (`alpha` 1) asks at most 2 batches, and asks the + second for want of a second answer only when it knows no sequence to compare the first against. + + A keeper query (a DHT lookup plus a few requests) costs roughly ten to twenty times the airtime of a + resolve a path response answers, so `always` SHOULD NOT be used on slow links. - Return the record with the highest sequence held, and how long ago it was heard (for a record from a keeper, from its signed emission time, never in the future). diff --git a/tests/security/test_m3_keeper_discovery.py b/tests/security/test_m3_keeper_discovery.py index fb1bcf2..9827923 100644 --- a/tests/security/test_m3_keeper_discovery.py +++ b/tests/security/test_m3_keeper_discovery.py @@ -5,19 +5,25 @@ that, D is one more DHT server. Squatter identities (never online, so they answer nothing) store provider records for name_key(N) after A's but before C's, either on B itself or on the servers B's lookup queries (B then holds none). A goes away and B resolves: the path request hears nothing, so B falls back to keepers. The tests check the outcome -only: B gets C's record, and C was asked. With alpha 3 two squatters are enough; on a slow node (alpha 1) one is. +only: B gets C's record, and C was asked. With alpha 3 two squatters were enough; on a slow node (alpha 1) one was. +Further tests pin down the fix: local records don't skip the search, keepers are ranked by routing-table standing +(a departed owner after a live keeper), and further batches are asked while answers are older than the sequence +already known or only one keeper answered, within a bound (two /name requests on a slow node). """ import io import os import tempfile import unittest +from unittest import mock import RNS from rnfs.config import Config +from rnfs.dht.routing import RoutingTable from rnfs.errors import NotFound from rnfs.features.keepers import name_key from rnfs.node import RnfsNode +from tests.support import FakeClock from tests.test_names import FakeNetwork from tests.test_names_keep import KeeperNet @@ -117,16 +123,12 @@ def test_control_slow_resolver_asks_keeper_without_squatters(self): self.forget_owner_record() self.assert_reached_keeper(self.resolve_without_owner()) - # M3: fails until ask_keepers treats local records as candidates and keeps looking past non-answering ones - @unittest.expectedFailure def test_squatters_at_resolver_do_not_hide_keeper(self): self.build() self.squat([self.b], SQUATTERS[:2]) self.keep_on_c() self.assert_reached_keeper(self.resolve_without_owner()) - # M3: fails until the provider search runs past the first alpha providers found and ranks them by more than age - @unittest.expectedFailure def test_squatters_at_servers_do_not_hide_keeper(self): self.build() self.squat([self.c, self.d], SQUATTERS[:2]) @@ -134,8 +136,6 @@ def test_squatters_at_servers_do_not_hide_keeper(self): self.forget_at_resolver() self.assert_reached_keeper(self.resolve_without_owner()) - # M3: fails until ask_keepers treats local records as candidates and keeps looking past non-answering ones - @unittest.expectedFailure def test_one_squatter_at_slow_resolver_does_not_hide_keeper(self): self.build(slow_resolver=True) self.assertEqual(self.b.config.effective_alpha(self.b.slow), 1) @@ -144,8 +144,6 @@ def test_one_squatter_at_slow_resolver_does_not_hide_keeper(self): self.forget_owner_record() self.assert_reached_keeper(self.resolve_without_owner()) - # M3: fails until the provider search runs past the first alpha providers found and ranks them by more than age - @unittest.expectedFailure def test_one_squatter_at_servers_does_not_hide_keeper_from_slow_resolver(self): self.build(slow_resolver=True) self.assertEqual(self.b.config.effective_alpha(self.b.slow), 1) @@ -155,14 +153,111 @@ def test_one_squatter_at_servers_does_not_hide_keeper_from_slow_resolver(self): self.forget_at_resolver() self.assert_reached_keeper(self.resolve_without_owner()) - # M3: fails until a resolve whose keepers all answer nothing asks a further batch - @unittest.expectedFailure def test_owners_stale_record_does_not_hide_keeper_from_slow_resolver(self): """No squatter at all: at alpha 1 the gone owner's own record, the oldest, takes the only slot.""" self.build(slow_resolver=True) self.keep_on_c() self.assert_reached_keeper(self.resolve_without_owner()) + def test_local_records_do_not_skip_the_search(self): + """alpha squatter records at the resolver, and none of the keeper's: the search still runs and finds C.""" + self.build() + self.squat([self.b], SQUATTERS[:3]) + self.keep_on_c() + self.b.records.withdraw(self.key, C_ID) + self.forget_owner_record() + self.assert_reached_keeper(self.resolve_without_owner()) + + def test_departed_owner_ranked_after_live_keeper(self): + """The owner's lookup query failed, so its own stale record is asked after the live keeper's.""" + self.build(slow_resolver=True) + self.keep_on_c() + result = self.resolve_without_owner() + self.assertEqual(self.net.name_requests[0], C_ID) + self.assert_reached_keeper(result) + + def test_slow_resolver_asks_at_most_two_keepers(self): + """On a slow node (alpha 1) a query is two batches of one at most, however many candidates there are.""" + self.build(slow_resolver=True) + self.squat([self.b], SQUATTERS) + self.assertEqual(self.b.feature("keepers").keeper_rounds(), 2) + self.resolve_without_owner() + self.assertLessEqual(len(self.net.name_requests), 2) + + def test_fast_resolver_asks_one_batch_when_two_keepers_answer(self): + """alpha 3: the first batch reaches A's replacement keepers C and D, which both answer; nothing more.""" + self.build() + self.keep_on_c() + keeper = self.d.feature("keepers") + keeper.keep(self.name) + keeper.run_pending() + self.net.sender = D_ID + self.d.publisher.publish_due() + self.squat([self.b], SQUATTERS) + with mock.patch.object(self.b.feature("keepers"), "rank_keepers", + lambda candidates: sorted(candidates, key=lambda p: (p not in (C_ID, D_ID), p))): + result = self.resolve_without_owner() + self.assertEqual(sorted(self.net.name_requests[:2]), [C_ID, D_ID]) + self.assertEqual(len(self.net.name_requests), 3) # one batch of alpha + self.assertEqual(result["sequence"], 1) + + def two_keepers_one_behind(self): + """C keeps sequence 1; A then publishes sequence 2, which D keeps and B resolves (so B knows 2).""" + self.keep_on_c() + cid2 = self.a.add_stream(io.BytesIO(b"v2"))["cid"] + self.a.name_publish("site", cid2) + keeper = self.d.feature("keepers") + keeper.keep(self.name) + keeper.run_pending() + self.assertEqual(keeper.store.get(self.name).sequence, 2) + self.net.sender = D_ID + self.d.publisher.publish_due() + self.assertEqual(self.b.name_resolve(self.name.hex())["sequence"], 2) + return cid2 + + def test_answer_older_than_known_asks_a_further_batch(self): + """Slow resolver: C, asked first, answers sequence 1 while B knows 2, so D is asked too.""" + self.build(slow_resolver=True) + cid2 = self.two_keepers_one_behind() + order = [C_ID, D_ID] + with mock.patch.object(self.b.feature("keepers"), "rank_keepers", + lambda candidates: sorted(candidates, key=lambda p: order.index(p) if p in order + else len(order))): + result = self.resolve_without_owner() + self.assertEqual(self.net.name_requests, [C_ID, D_ID]) + self.assertEqual((result["sequence"], result["cid"]), (2, cid2)) + + def test_current_answer_on_slow_resolver_asks_one(self): + """Slow resolver: D, asked first, answers the sequence B knows; no second /name is spent.""" + self.build(slow_resolver=True) + self.two_keepers_one_behind() + order = [D_ID, C_ID] + with mock.patch.object(self.b.feature("keepers"), "rank_keepers", + lambda candidates: sorted(candidates, key=lambda p: order.index(p) if p in order + else len(order))): + result = self.resolve_without_owner() + self.assertEqual(self.net.name_requests, [D_ID]) + self.assertEqual(result["sequence"], 2) + + +class StandingTest(unittest.TestCase): + """RoutingTable.standing, which ranks keepers: senior and healthy, healthy, stale, absent.""" + + def test_order(self): + clock = FakeClock(1000.0) + config = Config(provider_seniority=100, announce_interval=3600) + table = RoutingTable(bytes(16), config, clock=clock) + old, new, failing, absent = (bytes([x] * 16) for x in (1, 2, 3, 4)) + table.heard(old) + table.heard(failing) + clock.now += 200 + table.heard(new) + table.heard(old) + table.failed(failing) + self.assertEqual([table.standing(n, 100) for n in (old, new, failing, absent)], [0, 1, 2, 3]) + clock.now += 2 * 3600 + 1 # silent for two announce intervals + self.assertEqual(table.standing(old, 100), 2) + if __name__ == "__main__": unittest.main() From bfe9bc7fa5e67f6342c009a34c19efe0912da8eb Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:42:23 +1030 Subject: [PATCH 020/107] Test that ?filename= can't inject headers and that every API response is no-store and nosniff --- tests/security/test_response_headers.py | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/security/test_response_headers.py diff --git a/tests/security/test_response_headers.py b/tests/security/test_response_headers.py new file mode 100644 index 0000000..cd80c3a --- /dev/null +++ b/tests/security/test_response_headers.py @@ -0,0 +1,54 @@ +"""Response headers that keep the gateway and the local API safe, which no other test pins. + +- The gateway percent-encodes ?filename= in Content-Disposition, so a CR/LF in it can't add a header. +- Every API response, including a name's exported private key, carries Cache-Control: no-store and + X-Content-Type-Options: nosniff, on success and on error alike. +""" +import unittest + +from tests.test_api import ApiTestCase +from tests.test_gateway import GatewayTestCase + + +class ContentDispositionTest(GatewayTestCase): + """?filename= can't inject headers or break out of the filename* parameter.""" + + def test_filename_is_percent_encoded(self): + for name in ("a\r\nSet-Cookie: pwned=1", "a\nX-Injected: 1", 'x"; filename="evil.html', "a b;c=d"): + path = f"/rnfs/{self.text}?filename=" + "".join(f"%{b:02X}" for b in name.encode()) + status, headers, _ = self.get(path) + self.assertEqual(status, 200, name) + self.assertNotIn("Set-Cookie", headers, name) + self.assertNotIn("X-Injected", headers, name) + value = headers["Content-Disposition"] + self.assertTrue(value.startswith("inline; filename*=UTF-8''"), value) + encoded = value[len("inline; filename*=UTF-8''"):] + # Only RFC 5987 attr-chars and percent-escapes: no space, quote, semicolon, CR or LF + self.assertRegex(encoded, r"\A(?:[A-Za-z0-9!#$&+\-.^_`|~]|%[0-9A-F]{2})+\Z", name) + + +class ApiHeadersTest(ApiTestCase): + """no-store and nosniff on every kind of API response.""" + + def assert_safe_headers(self, headers, context): + self.assertEqual(headers["Cache-Control"], "no-store", context) + self.assertEqual(headers["X-Content-Type-Options"], "nosniff", context) + + def test_name_export(self): + self.ok("POST", "/api/name/create", {"alias": "site"}) + status, headers, _ = self.call("POST", "/api/name/export", {"alias": "site"}) + self.assertEqual(status, 200) + self.assert_safe_headers(headers, "name export") + + def test_other_responses(self): + for method, path, body, api in (("GET", "/api/status", None, False), # open to anyone + ("GET", "/api/name/list", None, True), + ("GET", "/api/name/list", None, False), # refused: no token + ("POST", "/api/name/export", {"alias": "missing"}, True), # 404 + ("GET", "/api/nothing-here", None, True)): + status, headers, _ = self.call(method, path, body, api=api) + self.assert_safe_headers(headers, (method, path, api, status)) + + +if __name__ == "__main__": + unittest.main() From d3a84934746e5952a6a580222edc5a9557e44bb7 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:43:38 +1030 Subject: [PATCH 021/107] Count directory nodes in full against a quota when holding requests to the room left --- design.md | 7 +++-- rnfs/fetcher.py | 52 +++++++++++++++++++++------------ specification.md | 6 ++-- tests/security/test_dag_caps.py | 18 ++++++++++++ 4 files changed, 58 insertions(+), 25 deletions(-) diff --git a/design.md b/design.md index b6e213b..db5ee2c 100644 --- a/design.md +++ b/design.md @@ -456,9 +456,10 @@ Everything in the specification is implemented, with these simplifications and a of what it allows). What it refuses: zero-size children (audit L3), and DAGs of chunks well under 4 KiB, which no writer following the tree rule makes. To keep the overshoot to about one block, `_take_batch` asks only for what fits the room left after the requests in flight (`_room`, `_reserve`): a raw chunk's - declared size, a directory or index node's header (its entries bring more room than they take, while - enough entries are left under `max_output_entries`), anything else `MAX_BLOCK_SIZE`; and always one CID - when nothing is in flight. Honest fetches are not held back by this: the content still to come leaves + declared size, anything else `MAX_BLOCK_SIZE`, except that under the size-derived bound (not under a + quota, which entries don't raise) a directory or index node takes only its header, since its entries bring + more room than they take while enough are left under `max_output_entries`; and always one CID when nothing + is in flight. Honest fetches are not held back by this: the content still to come leaves room for many file nodes, and directory nodes cost almost nothing. - `max_storage` is soft. An unpinned fetch evicts other cached fetches (LRU) to make room for each response and goes above `max_storage` when nothing is evictable; when its session ends the node evicts again, which diff --git a/rnfs/fetcher.py b/rnfs/fetcher.py index efc270d..39e6847 100644 --- a/rnfs/fetcher.py +++ b/rnfs/fetcher.py @@ -589,33 +589,47 @@ def _count_dag(self, cid: Cid, length: int, children: list[Expect] = ()): f"{plural(self._declared, 'byte')} of content and its directory entries can take: " "refusing it") - def _room(self, wanted: dict[Cid, list[Expect]]) -> tuple[int, int] | None: - """(bytes, entries) dag_limit() leaves for blocks not yet asked for, after the most the requests in - flight can bring; None if there is no limit.""" - limit = self.dag_limit() - if limit is None: + def _room(self, wanted: dict[Cid, list[Expect]]) -> tuple | None: + """What the limits leave for blocks not yet asked for, after the most the requests in flight can bring: + (bytes under the size-derived bound, entries it may still credit, bytes under max_dag_bytes), each None + if that limit doesn't apply; None if neither does.""" + if self._declared is None and self.max_dag_bytes is None: return None - room = (limit - self.dag_bytes, self.config.max_output_entries - self._entries) + bound = None + if self._declared is not None: + entries = min(self._entries, self.config.max_output_entries) + bound = dag_bound(self._declared) + DAG_ENTRY_OVERHEAD * entries - self.dag_bytes + quota = None if self.max_dag_bytes is None else self.max_dag_bytes - self.dag_bytes + room = (bound, self.config.max_output_entries - self._entries, quota) for cids, _ in self._in_flight.values(): for cid in cids: room = self._reserve(cid, wanted, room) return room - def _reserve(self, cid: Cid, wanted: dict[Cid, list[Expect]], room: tuple[int, int]) -> tuple[int, int]: - """room after the most cid can add to the DAG's bytes: a raw chunk's size; for a directory or index - node only its header, while the entries left can take the most a node holds (each brings more - allowance than it takes); otherwise MAX_BLOCK_SIZE.""" - room_bytes, room_entries = room + def _reserve(self, cid: Cid, wanted: dict[Cid, list[Expect]], room: tuple) -> tuple: + """room (as _room) after the most cid can add to the DAG's bytes: a raw chunk's declared size, otherwise + MAX_BLOCK_SIZE; under the size-derived bound, a directory or index node takes only its header while + enough entries are left for the most a node holds, since each brings more allowance than it takes.""" + bound, entries, quota = room expects = wanted.get(cid) if cid in self._counted: return room - if not expects: - return room_bytes - MAX_BLOCK_SIZE, room_entries - if cid.is_raw: - return room_bytes - (MAX_BLOCK_SIZE if expects[0].size is None else expects[0].size), room_entries - if room_entries >= NODE_ENTRIES and not any(walker.FILE_TYPE in expect.types for expect in expects): - return room_bytes - 8, room_entries - NODE_ENTRIES - return room_bytes - MAX_BLOCK_SIZE, room_entries + worst = MAX_BLOCK_SIZE + if expects and cid.is_raw and expects[0].size is not None: + worst = expects[0].size + if bound is not None: + if (expects and not cid.is_raw and entries >= NODE_ENTRIES + and not any(walker.FILE_TYPE in expect.types for expect in expects)): + bound, entries = bound - 8, entries - NODE_ENTRIES + else: + bound -= worst + return bound, entries, None if quota is None else quota - worst + + @staticmethod + def _fits(room: tuple) -> bool: + """True if room (as _room) leaves nothing below zero.""" + bound, _, quota = room + return (bound is None or bound >= 0) and (quota is None or quota >= 0) def _accept(self, cid: Cid, block: bytes, wanted: dict[Cid, list[Expect]], children: dict): """Check a verified block against every expectation of it and the limits, then store it. @@ -776,7 +790,7 @@ def _take_batch(self, pending: deque, node_id: bytes, wanted: dict[Cid, list[Exp break if room is not None: room = self._reserve(cid, wanted, room) - if room[0] < 0 and (batch or self._in_flight): + if not self._fits(room) and (batch or self._in_flight): break batch.append(pending.popleft()) total += guess diff --git a/specification.md b/specification.md index 83c8a4f..33d9db2 100644 --- a/specification.md +++ b/specification.md @@ -543,9 +543,9 @@ and repeat. under 13 bytes per 4 KiB of content, and each entry, with the nodes above it and the levels of a file below it, under 1 KiB. A fetch that would exceed it fails ("too large") without marking the root invalid. While the bound applies, a requester asks only for blocks that fit the room it leaves, counting a DAG node - not yet received as `MAX_BLOCK_SIZE` (a directory or index node as its header only, since its entries bring - more room than they take), and always for at least one block when none is in flight, so a fetch downloads - at most about one block beyond the bound. Byte-range reads are lazy and skip empty children instead. + not yet received as `MAX_BLOCK_SIZE` (under the size-derived bound, a directory or index node as its header + only, since its entries bring more room than they take; under a quota, in full), and always for at least + one block when none is in flight, so a fetch downloads at most about one block beyond either. Byte-range reads are lazy and skip empty children instead. 6. Spread requests over up to `max_parallel_providers` providers (1 on slow nodes, where parallel transfers only contend for the same channel). A response *stalls* if it makes no progress for `stall_timeout`, or misses the same deadline the responder uses (section 3). A stalled provider's link is closed, which cancels diff --git a/tests/security/test_dag_caps.py b/tests/security/test_dag_caps.py index 723ce37..573c218 100644 --- a/tests/security/test_dag_caps.py +++ b/tests/security/test_dag_caps.py @@ -264,6 +264,24 @@ def test_dir_set_with_a_child_that_breaks_the_bound(self): self.b.dir_set(str(directory), "child", str(bomb.root)) self.assertEqual(self.b.storage.pins(), {}) + def test_a_quota_is_overshot_by_one_block_at_most(self): + # Directory nodes bring room under the size-derived bound, but not under a quota: they count in full there + with self.a.storage.protect() as session: + empty = builder.build_bytes(b"", session.put).cid + subdirectories = [] + for i in range(20): + built = builder.build_directory([dag.Entry(name(j, 100) + str(i), empty, 0, 0) for j in range(250)], + session.put) + subdirectories.append(dag.Entry(name(i, 8), built.cid, 0, dag.KIND_DIRECTORY)) + root = builder.build_directory(subdirectories, session.put).cid + self.a.storage.pin(root, USER) + self.a.publisher.refresh() + self.a.records.store(root.key, A_ID) + with self.assertRaises(TooLarge), self.b.fetcher.open(root, pinned=True) as fetch: + fetch.max_dag_bytes = 50_000 + fetch.fetch_dag(fetch.resolve()) + self.assertLessEqual(self.net.downloaded_bytes, 50_000 + MAX_BLOCK_SIZE) + def test_a_lower_quota_still_applies(self): cid = self.add_file(4 * CHUNK_SIZE) with self.assertRaises(TooLarge) as caught, self.b.fetcher.open(cid, pinned=True) as fetch: From 97b2537d4b2bce49df2ff2cf54a66589bbf007dc Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:43:46 +1030 Subject: [PATCH 022/107] Keep resolved names in the name table under announce floods and save a sequence floor for gateway resolves --- CHANGELOG.md | 5 + design.md | 8 +- rnfs/features/lxmf.py | 3 +- rnfs/names.py | 145 +++++++++++++++++++++--- specification.md | 13 ++- tests/security/test_l4_name_rollback.py | 72 +++++++++++- 6 files changed, 222 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fc754..549c6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ DHT and names: each group, and asked a batch at a time. Another batch is asked while no answer reaches the sequence already known or only one keeper answered, at most 3 batches (2 on slow nodes, where a second `/name` is spent only if there's no known sequence to check the first answer against). +- Gateway and LXMF auto-fetch name resolves can no longer be rolled back by flooding announces of new names + and replaying an old one: an announce of a name the node isn't resolving or keeping only takes the table + place of another name that was only heard, and the highest sequence of each name resolved this way is saved + in `state/name-floor.json` (up to 4 × `name_table_size` names), so no older record is accepted after table + churn or a restart. - A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own client never sends more. diff --git a/design.md b/design.md index 5364fb2..287dc94 100644 --- a/design.md +++ b/design.md @@ -400,7 +400,8 @@ Under `/state/`, JSON written atomically from a temp file synced to di (storage); `pin_names.json` (rnfs/pin_names.py: where each pin was added from, local only, pruned to the pin list); `providers.json` (provider records); `published.json` (publisher: last rounds, earlier rounds' nodes, pending withdrawals); `fetched.json` (targets fetched for the user, for `roots`); `names.json` (sequences, name -table); `invalid.json` (invalid roots); `shared_folder.json`; `jobs.json` (results keep at most 100 warnings +table); `name-floor.json` (highest sequence of names resolved for the gateway or auto-fetch, at most +4 × `name_table_size`, saved by the re-announce loop when changed); `invalid.json` (invalid roots); `shared_folder.json`; `jobs.json` (results keep at most 100 warnings plus `warnings_total`; older results are capped at load); `keepers.json` (records re-verified at load); `follow.json`; `contacts.json`; `lxmf_inbox.json` (500 messages, 4 MiB, 50 per untrusted sender, 16 KiB per record) and `lxmf_outbox.json` (200; unfinished messages are marked failed at a restart), both UTF-8 JSON with @@ -500,7 +501,10 @@ Everything in the specification is implemented, with these simplifications and a nodes, where one current answer is enough once a sequence is known). - **Names.** A key imported, or found without an entry in `names.json`, is marked `imported`: its first publish offers kept records, makes a path request and queries keepers before choosing a sequence. The pins of name - keys deleted by hand are released by `release_gone()`, once the features are set up. + keys deleted by hand are released by `release_gone()`, once the features are set up. The name table marks + entries that were only heard (`_heard_only`, its own LRU, so finding one to evict is O(1)); an announce with + no waiting resolve, of a name without a kept record, may only evict one of those. A resolve with + `persist=False` (gateway, LXMF auto-fetch) raises the name's floor (`_floor`), which `_offer` checks. - **Follow** isn't built on jobs (job pins use source `user`); `pin add --follow` doesn't exist. Checks use the record held unless nothing was heard for 2 × `announce_interval`; a failed check (any exception) is retried after 300 s, doubling up to `announce_interval`, and a failed fetch is retried with the record held diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index 43f3d85..dca2fe6 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -1009,7 +1009,8 @@ def _resolve(self, link: Link, persist: bool = True) -> tuple[Cid, list[str]]: """The root CID and path a link names, resolving a name (falling back to the CID it was sent with). persist False (auto-fetch): the name's record goes only into the bounded table, so names arriving - unasked don't grow names.json.""" + unasked don't grow names.json; its sequence still goes into the bounded, saved floor, so a later + auto-fetch of the name never resolves to an older record.""" if link.name is None: return link.cid, list(link.path) from rnfs.errors import NotFound diff --git a/rnfs/names.py b/rnfs/names.py index ef19c3f..1010337 100644 --- a/rnfs/names.py +++ b/rnfs/names.py @@ -4,6 +4,8 @@ `[sequence, binary CID]` in msgpack, rides in the destination's announce app_data, which RNS signs. RNS stores any validly signed announce, including a replayed old one, so the highest sequence seen is persisted for every name this node publishes or resolves for the user, and lower sequences are never accepted from any source. +Names resolved for the gateway or LXMF auto-fetch keep their records in a bounded table, where announces of +names this node never resolved can't push them out, and their highest sequence in a bounded persisted floor. Every RNS call goes through `RnsNetwork`, so tests swap in a fake and run without Reticulum. """ @@ -22,6 +24,7 @@ from rnfs.config import Config from rnfs.constants import APP_NAME, ID_LENGTH, NAME_ASPECT from rnfs.content.cid import Cid +from rnfs.content.storage import read_json from rnfs.errors import NotFound, RnfsError from rnfs.log import LOG_ERROR, LOG_NOTICE, LOG_VERBOSE, LOG_WARNING, log @@ -33,6 +36,9 @@ MAX_RECORD_SIZE = 64 STATE_FILE = "names.json" STATE_VERSION = 1 +FLOOR_FILE = "name-floor.json" +FLOOR_VERSION = 1 +FLOOR_FACTOR = 4 # the floor holds up to this many times name_table_size names, so it outlasts table churn PIN_SOURCE_PREFIX = "name:" _ALIAS = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") _POLL_INTERVAL = 0.25 # how often a refresh checks whether RNS has heard a path response @@ -229,6 +235,16 @@ def __init__(self, config: Config, pins, network=None, clock=time.time, monotoni self._held: dict[bytes, Record] = {} # Every other name's record: in memory, least recently used first, at most name_table_size self._table: OrderedDict[bytes, Record] = OrderedDict() + # The table's names that were only heard, never resolved here, least recently used first. An announce of a + # name this node isn't resolving and doesn't know may only take the place of one of these, so a flood of + # new names can't push out the names it resolved + self._heard_only: OrderedDict[bytes, None] = OrderedDict() + # Highest sequence of each name resolved without persist (gateway, LXMF auto-fetch), least recently resolved + # first, at most FLOOR_FACTOR × name_table_size: saved, so such a resolve never goes back after table churn + # or a restart + self._floor: OrderedDict[bytes, int] = OrderedDict() + self._floor_path = os.path.join(config.state_dir, FLOOR_FILE) + self._floor_dirty = False self._held_dirty = False # heard times changed without a save self._destinations: dict[str, object] = {} @@ -245,6 +261,7 @@ def __init__(self, config: Config, pins, network=None, clock=time.time, monotoni self._remove_old_temps() self._load_keys() self._load_state() + self._load_floor() # Keys @@ -368,7 +385,7 @@ def publish(self, alias: str, cid: Cid) -> dict: self._pins.unpin(previous.cid, source) self._published[alias] = _Published(sequence, cid) - self._table.pop(name, None) + self._drop_from_table(name) self._held[name] = Record(sequence, cid, self._clock()) self._save() self._announce(alias) @@ -408,6 +425,7 @@ def stop(self): for event in events: event.set() self._save() + self._save_floor() def announce_due(self) -> float: """Re-announce every name whose announce_interval has passed; seconds until the next is due.""" @@ -418,6 +436,8 @@ def announce_due(self) -> float: self._announce(alias) if self._held_dirty: self._save() + if self._floor_dirty: + self._save_floor() if not self._next_announce: return self.config.announce_interval return max(0.0, min(self._next_announce.values()) - now) @@ -438,8 +458,9 @@ def resolve(self, name: bytes, persist: bool, max_age: float = 0.0) -> dict | No """The highest record held for name after refreshing it: {"name", "cid", "sequence", "age"}, or None. persist is True for the CLI and API (the highest sequence is then kept forever) and False for the gateway - (the record goes only into the bounded table). The network refresh, which can take path_timeout, is - skipped for this node's own names and when the held record was heard less than max_age seconds ago. + and LXMF auto-fetch (the record goes only into the bounded table, and its sequence into the bounded floor). + The network refresh, which can take path_timeout, is skipped for this node's own names and when the held + record was heard less than max_age seconds ago. """ name = _check_name(name) self._offer_kept(name) @@ -470,9 +491,11 @@ def resolve(self, name: bytes, persist: bool, max_age: float = 0.0) -> dict | No if record is None: return None if persist and name not in self._held: - self._table.pop(name, None) + self._drop_from_table(name) self._held[name] = record self._save() + elif name not in self._held: + self._resolved(name, record.sequence) return {"name": name.hex(), "cid": str(record.cid), "sequence": record.sequence, "age": max(0.0, self._clock() - record.heard)} @@ -539,9 +562,13 @@ def _absorb_recalled(self, name: bytes): def _heard(self, name: bytes, app_data, packet_hash: bytes | None = None): """Announce handler: apply a heard record and wake resolves waiting for it.""" parsed = parse_record(app_data) + # A name kept here may take a table place like a resolved one (asked outside our lock, as always) + kept = parsed is not None and self.keepers is not None and self.keepers.local_record(bytes(name)) is not None with self._lock: if parsed is not None: - self._offer(bytes(name), parsed[0], parsed[1], self._clock()) + # Unasked: not being resolved (a path response would have a waiter) nor kept + unasked = not kept and name not in self._waiters + self._offer(bytes(name), parsed[0], parsed[1], self._clock(), unasked=unasked) else: log(f"ignored malformed record for name {RNS.prettyhexrep(name)}", LOG_VERBOSE) for event in self._waiters.get(name, ()): @@ -576,13 +603,21 @@ def _record(self, name: bytes, touch: bool) -> Record | None: record = self._table.get(name) if record is not None and touch: self._table.move_to_end(name) + if name in self._heard_only: + self._heard_only.move_to_end(name) return record - def _offer(self, name: bytes, sequence: int, cid: Cid, heard: float) -> bool: - """Apply a validly signed record: True if it replaced the one held. Lower sequences are refused, and at - an equal sequence the record already held is kept.""" + def _offer(self, name: bytes, sequence: int, cid: Cid, heard: float, unasked: bool = False) -> bool: + """Apply a validly signed record: True if it replaced the one held. Lower sequences (than the record held, + or the floor of a name resolved before) are refused, and at an equal sequence the record already held is + kept. unasked: an announce of a name this node isn't resolving or keeping, which takes a table place only + if it is free or held by another name that was only heard.""" held_here = name in self._held current = self._record(name, touch=False) + floor = None if held_here else self._floor.get(name) + if floor is not None and sequence < floor: + log(f"refused record {sequence} for name {name.hex()}: already resolved {floor}", LOG_VERBOSE) + return False if current is not None: if sequence < current.sequence: log(f"refused record {sequence} for name {name.hex()}: already hold {current.sequence}", @@ -594,23 +629,69 @@ def _offer(self, name: bytes, sequence: int, cid: Cid, heard: float) -> bool: self._store(name, Record(sequence, cid, heard), held_here) self._held_dirty |= held_here elif not held_here: - self._table.move_to_end(name) + self._touch(name) return False - self._store(name, Record(sequence, cid, heard), held_here) + if not self._store(name, Record(sequence, cid, heard), held_here, unasked): + return False if held_here: self._save() return True - def _store(self, name: bytes, record: Record, held_here: bool): - """Put record in the persisted map or the bounded table, evicting the least recently used.""" + def _store(self, name: bytes, record: Record, held_here: bool, unasked: bool = False) -> bool: + """Put record in the persisted map or the bounded table, making room in the table. False if an unasked + record found no room (every place is a name resolved or kept here).""" if held_here: self._held[name] = record - return + return True + limit = max(0, self.config.name_table_size) + if name not in self._table: + # Names only heard make room first; one resolved or kept here only for another such name + while len(self._table) >= limit and self._heard_only: + evicted, _ = self._heard_only.popitem(last=False) + del self._table[evicted] + if len(self._table) >= limit and unasked: + return False + while self._table and len(self._table) >= limit: + self._table.popitem(last=False) + if unasked: + self._heard_only[name] = None self._table[name] = record + self._touch(name) + floor = self._floor.get(name) + if floor is not None and record.sequence > floor: + self._floor[name] = record.sequence + self._floor_dirty = True + if len(self._table) > limit: # name_table_size 0: nothing is kept + self._drop_from_table(name) + return True + + def _touch(self, name: bytes): + """Mark a table entry recently used.""" self._table.move_to_end(name) - while len(self._table) > max(0, self.config.name_table_size): - self._table.popitem(last=False) + if name in self._heard_only: + self._heard_only.move_to_end(name) + + def _drop_from_table(self, name: bytes): + """Remove name from the table (it is held now).""" + self._table.pop(name, None) + self._heard_only.pop(name, None) + + def _resolved(self, name: bytes, sequence: int): + """A resolve without persist returned name's record: it is no longer only heard, and its sequence is the + floor for later resolves (least recently resolved forgotten first).""" + self._heard_only.pop(name, None) + previous = self._floor.pop(name, None) + self._floor[name] = sequence if previous is None else max(previous, sequence) + if self._floor[name] != previous: + self._floor_dirty = True + while len(self._floor) > self._floor_size(): + self._floor.popitem(last=False) + self._floor_dirty = True + + def _floor_size(self) -> int: + """How many names the floor holds at most.""" + return FLOOR_FACTOR * max(0, self.config.name_table_size) # RNS destinations; callers hold the lock @@ -744,6 +825,40 @@ def _load_state(self): # elsewhere: treated as imported, so its first publish looks for its records first self._published.setdefault(alias, _Published(0, None, imported=True)) + def _load_floor(self): + """Read the floor of names resolved without persist. A damaged file is moved aside and the node starts + without it (it only bounds rollback of gateway and auto-fetch resolves); entries that don't parse are + skipped.""" + state = read_json(self._floor_path, shape=lambda value: isinstance(value, dict) + and value.get("version") == FLOOR_VERSION and isinstance(value.get("floor"), list)) + if state is None: + return + for entry in state["floor"]: + try: + name_hex, sequence = entry + name = parse_name(name_hex) + except (TypeError, ValueError): + continue + if type(sequence) is int and 0 <= sequence <= MAX_SEQUENCE and name not in self._held: + self._floor.pop(name, None) + self._floor[name] = sequence + while len(self._floor) > self._floor_size(): + self._floor.popitem(last=False) + + def _save_floor(self): + """Write the floor atomically, least recently resolved first, if it changed. Saved by announce_due (at + least every minute while running) and stop(), not on every resolve, since it holds thousands of names.""" + if not self._floor_dirty: + return + state = {"version": FLOOR_VERSION, "floor": [[name.hex(), sequence] for name, sequence in self._floor.items()]} + os.makedirs(self.config.state_dir, exist_ok=True) + try: + fsutil.write_atomic(self._floor_path, json.dumps(state, separators=(",", ":")).encode()) + except OSError as e: + log(f"could not save {self._floor_path}: {e}", LOG_WARNING) + return + self._floor_dirty = False + def _save(self): """Write published and held records atomically.""" state = { diff --git a/specification.md b/specification.md index 7d87349..10c5ed9 100644 --- a/specification.md +++ b/specification.md @@ -673,8 +673,15 @@ what prevent rollback. - For every name this node has published, or resolved through the CLI or API, it MUST persist the highest sequence seen, and never accept a lower one from any source. - At equal sequence, the record already held is kept. -- Records for other names (heard, or resolved by the gateway) are kept only in a table of at most - `name_table_size` entries, least recently used evicted first. +- Records for other names (heard, or resolved by the gateway or LXMF auto-fetch) are kept only in a table of + at most `name_table_size` entries, least recently used evicted first. An announce of a name the node isn't + resolving or keeping takes a place only if one is free or held by another name that was only heard, so a + flood of announces of new names can't push out the names the node resolved. +- For names resolved without being persisted (gateway, auto-fetch), the node SHOULD persist the highest + sequence it returned, as a floor below which no record of that name is accepted, so such a resolve never + goes back after table churn or a restart. The record itself isn't persisted. (rnfs: up to + 4 × `name_table_size` names, least recently resolved forgotten first, saved at least every minute and at + shutdown.) - Records from keepers are subject to the same persisted-sequence and no-rollback rules as announces. - A record this node keeps (below) is a floor for resolving that name: it MUST be offered to the sequence rules at startup and on every resolve, so an older validly signed announce is never accepted for a kept @@ -1023,7 +1030,7 @@ digits). A node MUST NOT start with an invalid setting. | `max_storage` | 10 GiB | | | `max_download_size` | none | | | `max_output_entries` | 100 000 | | -| `name_table_size` | 10 000 | Records kept for names this node hasn't resolved through the CLI or API | +| `name_table_size` | 10 000 | Records kept for names this node hasn't resolved through the CLI or API (four times as many sequence floors, section 9) | | `remote_pin_quota` | 1 GiB per remote identity | | | `remote_pin_identities` | empty | Identities allowed to send `/pin` | | `private` | off | Private mode (section 3) | diff --git a/tests/security/test_l4_name_rollback.py b/tests/security/test_l4_name_rollback.py index be4a50f..4e81ca6 100644 --- a/tests/security/test_l4_name_rollback.py +++ b/tests/security/test_l4_name_rollback.py @@ -4,13 +4,16 @@ A NameManager over FakeNetwork resolves name N for the gateway (persist=False) at sequence 5. Then fresh names, as many as the default name_table_size (10,000; about 0.35 s here), are announced, and N's older announce at sequence 3 is replayed. The next gateway resolve must not return less than 5. A control shows the replay is refused when no flood -has evicted N. +has evicted N. Further tests pin down the fix: unasked announces take only places of names that were only heard, and +the highest sequence of names resolved for the gateway is a saved floor that outlasts table churn and restarts. """ +import os import tempfile import unittest import RNS +from rnfs import names from rnfs.config import Config from rnfs.content.cid import RAW, Cid from rnfs.names import NameManager, encode_record @@ -58,8 +61,6 @@ def test_control_replay_refused_without_flood(self): result = self.manager.resolve(N, persist=False) self.assertEqual((result["sequence"], result["cid"]), (5, str(NEW))) - # L4: fails until announces only update names this node resolved or keeps, or a floor survives eviction - @unittest.expectedFailure def test_no_rollback_after_table_churn(self): for i in range(self.config.name_table_size): self.clock.now += 0.01 @@ -68,6 +69,71 @@ def test_no_rollback_after_table_churn(self): result = self.manager.resolve(N, persist=False) self.assertGreaterEqual(result["sequence"], 5, "gateway resolve went back from sequence 5") + def test_flood_takes_only_heard_places(self): + """Announces of names never resolved here fill only the table places not held by resolved names.""" + for i in range(2 * self.config.name_table_size): + self.network.announce(flood_name(i), encode_record(1, OLD), self.clock.now) + self.assertIn(N, self.manager._table) + self.assertEqual(len(self.manager._table), self.config.name_table_size) + + def test_no_rollback_after_restart(self): + """The floor is saved: after a restart the old announce is still refused, a newer one accepted.""" + self.manager.stop() + self.network.app_data.clear() + restarted = NameManager(self.config, FakePins(), network=self.network, clock=self.clock, monotonic=self.clock) + restarted.start() + self.addCleanup(restarted.stop) + self.replay_old() + self.assertIsNone(restarted.resolve(N, persist=False)) # the record itself isn't kept, only the floor + self.network.announce(N, encode_record(6, NEW), self.clock.now) + self.assertEqual(restarted.resolve(N, persist=False)["sequence"], 6) + + +class FloorChurnTest(unittest.TestCase): + """Gateway resolves of other names push N out of a small table, not out of the larger floor.""" + + def setUp(self): + """A manager with a table of 3 names, which resolved N at sequence 5.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.config = Config(rnfs_dir=tmp.name, path_timeout=0.05, name_table_size=3) + self.network = FakeNetwork() + self.clock = FakeClock(1_000_000.0) + self.manager = NameManager(self.config, FakePins(), network=self.network, clock=self.clock, + monotonic=self.clock) + self.manager.start() + self.addCleanup(self.manager.stop) + self.network.announce(N, encode_record(5, NEW), self.clock.now) + self.assertEqual(self.manager.resolve(N, persist=False)["sequence"], 5) + + def test_resolve_churn(self): + for i in range(5): + self.network.announce(flood_name(i), encode_record(1, OLD), self.clock.now) + self.manager.resolve(flood_name(i), persist=False) + self.assertNotIn(N, self.manager._table) + self.network.app_data.clear() + self.network.announce(N, encode_record(3, OLD), self.clock.now) + result = self.manager.resolve(N, persist=False) + self.assertTrue(result is None or result["sequence"] >= 5, f"gateway resolve went back from 5: {result}") + + def test_floor_bounded(self): + for i in range(names.FLOOR_FACTOR * 3 + 5): + self.network.announce(flood_name(i), encode_record(1, OLD), self.clock.now) + self.manager.resolve(flood_name(i), persist=False) + self.assertEqual(len(self.manager._floor), names.FLOOR_FACTOR * 3) + + def test_damaged_floor_file_is_set_aside(self): + self.manager.stop() + path = os.path.join(self.config.state_dir, names.FLOOR_FILE) + with open(path, "w", encoding="utf-8") as f: + f.write("{not json") + restarted = NameManager(self.config, FakePins(), network=self.network, clock=self.clock, monotonic=self.clock) + self.assertEqual(len(restarted._floor), 0) + self.assertTrue(any(entry.startswith(names.FLOOR_FILE + ".damaged") for entry in os.listdir(self.config.state_dir))) + if __name__ == "__main__": unittest.main() From ed92082c90a770bdf156e7294dbd069dce61f898 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:44:14 +1030 Subject: [PATCH 023/107] State in the specification that a node's names are linked to it through its provider records --- CHANGELOG.md | 3 +++ specification.md | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 549c6a7..e9b2da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ DHT and names: place of another name that was only heard, and the highest sequence of each name resolved this way is saved in `state/name-floor.json` (up to 4 × `name_table_size` names), so no older record is accepted after table churn or a restart. +- The specification no longer claims that a node's names can't be linked to it or to each other: the node + keeps its own names and provides their targets under its node ID, so its names are linked to it, as + providing content reveals the provider in IPFS. Behaviour is unchanged. - A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own client never sends more. diff --git a/specification.md b/specification.md index 10c5ed9..6e180fb 100644 --- a/specification.md +++ b/specification.md @@ -624,8 +624,14 @@ A **name** is a mutable pointer to a CID, owned by a key pair: the IPNS equivale **Name keys.** - Each name has its own RNS identity, stored at `/names/`. - The name is the hash of that identity's `rnfs.name` destination: 16 bytes, shown as 32 hex characters. -- Separate keys mean names can't be linked to each other or to the node, and a name can be moved to another - machine by exporting its key. +- A separate key per name means a name can be moved to another machine by exporting its key, and that the name, + its announces and its signed records carry nothing that identifies the node or the node's other names. +- Publishing a name does link it to the node, as providing content does in IPFS (a provider record reveals + the PeerID providing a CID). The owner keeps its own names (Keepers, below), so it provides each name's key + under its node ID, usually with the oldest record; and it pins and provides each name's target. Anyone who + knows a name can look up its keepers and its target's providers and find the owner's node ID there, so the + names one node publishes can be linked to that node and to each other. Only names published from different + nodes are unlinked. **Record.** - The name destination's announce `app_data` is `[sequence, cid]` (msgpack: unsigned integer, binary CID). @@ -706,8 +712,10 @@ resolves after its owner's announce has expired from the network. once it holds a record of the name. The prefix keeps these keys apart from CID keys (SHA-256 of a 36- or 39-byte binary CID). - **What is kept.** A node keeps the names its user keeps (`name keep`) or follows, at most `keep_max_names`, - and MUST keep its own published names, with records it signs itself, so they resolve through it. Each kept - name costs a provider record republished by this node (and, if followed, a pin), hence the small default. + and MUST keep its own published names, with records it signs itself, so they resolve through it. Keeping + advertises the node's ID under the name's key, which links the node to every name it keeps or publishes + (Name keys, above). Each kept name costs a provider record republished by this node (and, if followed, a + pin), hence the small default. Kept names still without a record are asked for again every `announce_interval`, in turn (rnfs: at most 64 per round, continuing after the last one asked). - **Refreshing.** A keeper replaces its record when it gets a verifying record with a higher sequence, or the @@ -787,6 +795,8 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. - With the default `roots` strategy, complete downloads are published and served while cached, which reveals them. With `pinned`, downloads are neither published nor served. Gateway fetches never are. - DHT keys don't hide which CID is wanted from someone who already knows the CID. +- Provider records are public: anyone can look up which node IDs provide a CID, or keep a name. A node's own + names are linked to it this way (section 9). - Transport nodes on the path see traffic volume and timing. - Content is public by CID. Sealed files (section 5) keep their content and name from everyone but their recipients, not their size or recipient list. From dbd11445257fd8d8d6037eee35b6ea09e4c5d017 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:45:54 +1030 Subject: [PATCH 024/107] Make /blocks displacement protection earned by progress --- rnfs/config.py | 5 +- rnfs/network/server.py | 249 ++++++++++++- tests/security/_server_helpers.py | 84 +++++ tests/security/test_h1_response_slots.py | 388 +++++++++++++++++++++ tests/security/test_rns_compat_security.py | 94 +++++ 5 files changed, 802 insertions(+), 18 deletions(-) create mode 100644 tests/security/_server_helpers.py create mode 100644 tests/security/test_h1_response_slots.py create mode 100644 tests/security/test_rns_compat_security.py diff --git a/rnfs/config.py b/rnfs/config.py index 27d291c..24564d6 100644 --- a/rnfs/config.py +++ b/rnfs/config.py @@ -28,7 +28,8 @@ DURATIONS = { # seconds: name → True if it must be above 0 (else 0 is allowed too) "republish_interval": True, "announce_interval": True, "startup_announce_delay": False, "provider_seniority": False, "response_target": True, "deadline_slack": False, "stall_timeout": True, - "lookup_timeout": True, "path_timeout": True, "link_idle_timeout": True, "ban_time": False, "scan_interval": True, + "blocks_cooldown": False, "lookup_timeout": True, "path_timeout": True, "link_idle_timeout": True, + "ban_time": False, "scan_interval": True, } OPTIONAL = ("republish_interval", "max_upload_rate") # these may also be unset (None) # Some durations are waited on, and Windows can wait at most ~49 days at once (threading.TIMEOUT_MAX) @@ -72,6 +73,8 @@ class Config: response_target: float = 60 deadline_slack: float = 30 stall_timeout: float = 120 + # /blocks refused to a link (and identity) whose response was displaced or cancelled for making no progress + blocks_cooldown: float = 60 lookup_timeout: float = 600 path_timeout: float = 30 max_failures: int = 3 diff --git a/rnfs/network/server.py b/rnfs/network/server.py index d1bb006..96a1f30 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -14,10 +14,11 @@ cancelled with Resource.cancel(). A response keeps its slots until its Resource concludes. These are RNS 1.5.6 internals; if they are missing, a response is cancelled by closing its link. """ +import math import random import threading import time -from collections import OrderedDict +from collections import Counter, OrderedDict from typing import Callable, Protocol import RNS @@ -42,6 +43,20 @@ MAX_PACING_WAIT = protocol.REQUEST_TIMEOUT_WITHOUT_RTT / 2 CLOSED_MEMORY = 1024 # closed incoming links remembered, so a late request can't track them again +# Displacement protection (section 3): a response is protected for a grace the node measures itself, then only +# while it makes progress. Never from link.rtt, which the requester sets +GRACE_BASE = 3.0 # seconds: handling at both ends and a round trip on a fast link, before the first part request +# A Resource advertisement fills at most one packet at RNS's default MTU (ResourceAdvertisement.HASHMAP_MAX_LEN is +# sized to Link.MDU), so it takes at most this long per hop at the interface's bitrate +ADVERTISEMENT_BITS = 500 * 8 +MAX_GRACE_HOPS = 32 # the requester can raise the hop count it reports; the grace is capped anyway +# Progress floor on a directly connected link: 1/1000 of the interface's bitrate (1.25 kB/s on a 10 Mbit/s TCP +# interface). Far below what an honest transfer gets from a direct neighbour, but a requester trickling below it +# loses its protection. Behind more hops the path may be much slower than our interface, so only the +# unmeasured_bitrate floor applies there +FLOOR_FRACTION = 1 / 1000 +COOLDOWN_MEMORY = 4096 # links and identities on a /blocks cooldown remembered at most + class Backend(Protocol): """What RequestServer needs from the node. Called on RNS request threads; must be thread-safe.""" @@ -98,6 +113,48 @@ def select_blocks(cids: list[Cid], served_block: Callable[[Cid], bytes | None], return entries +def link_path(link) -> tuple[object | None, float | None, int | None]: + """(receiving interface, its bitrate in bits/s, hop count) of an incoming link; None for what is unknown. + + RNS 1.5.6 dependency: Link.attached_interface (the interface the link request arrived on), Interface.bitrate + (the node's own configuration, or the interface's own guess) and Link.expected_hops (the hop count of the + requester's RTT packet, which the requester can raise but transport nodes only increase). Read with + defaults, so a changed RNS falls back to the conservative values the callers use for unknowns. + """ + interface = getattr(link, "attached_interface", None) + bitrate = getattr(interface, "bitrate", None) + if type(bitrate) not in (int, float) or not math.isfinite(bitrate) or bitrate <= 0: + bitrate = None + hops = getattr(link, "expected_hops", None) + if type(hops) is not int or hops < 0: + hops = None + return interface, bitrate, hops + + +def response_grace(bitrate: float | None, hops: int | None, config: Config) -> float: + """Seconds a new response is protected from displacement before it must show progress (section 3). + + Enough for the advertisement to reach the requester and its first part request to come back over hops hops + at the receiving interface's bitrate (unmeasured_bitrate if unknown), plus GRACE_BASE, at most + deadline_slack. E.g. 3 hops at 1 kbit/s: 3 + 2 × 3 × 4 s = 27 s; any hop count over TCP: about 3 s. + """ + rate = bitrate if bitrate else config.unmeasured_bitrate + hops = min(max(1, hops or 1), MAX_GRACE_HOPS) + return min(config.deadline_slack, GRACE_BASE + 2 * hops * ADVERTISEMENT_BITS / rate) + + +def progress_floor(bitrate: float | None, hops: int | None, config: Config) -> float: + """Bytes/s a response must keep up, once its grace is over, to stay protected (section 3). + + unmeasured_bitrate (which every honest path is assumed to beat), or for a directly connected requester + FLOOR_FRACTION of the interface's bitrate if that is more. + """ + floor = config.unmeasured_bitrate / 8 + if bitrate and hops == 1: + floor = max(floor, bitrate * FLOOR_FRACTION / 8) + return floor + + class Response: """A /blocks response in progress, holding a slot in one of the pools.""" @@ -118,17 +175,59 @@ def __init__(self, link_id: bytes, request_id: bytes, pool: str, started: float) self.done = threading.Event() # set once finished, to cut short a pacing wait self.charged = 0 # bytes of upload_budget charged and not yet settled (refunded, or kept once sent) self.requester: bytes | None = None # identity hash (else link ID) whose budget share it counts against + self.identity: bytes | None = None # the requester's identity hash, if it identified + # Displacement protection (section 3) + self.grace = float("inf") # seconds from grace_start it is protected whatever its progress + self.grace_start = started # restarted when it is handed to RNS and when its Resource is advertised + self.floor = 0.0 # bytes/s it must keep up after its grace to stay protected + self.interface = None # the RNS interface its request arrived on, if known + self.progress_at = started # when sent last grew (or it was handed to RNS) + self.first_progress = None # when sent was first seen above 0 + self.idle = False # displaced or cancelled for making no progress: its requester cools down def throughput(self, now: float) -> float: """Bytes per second sent so far.""" return self.sent / max(now - self.started, 1e-6) + def note_progress(self, sent: float, now: float): + """Record the bytes sent so far, as read from its Resource.""" + if sent > self.sent: + self.progress_at = now + if self.first_progress is None: + self.first_progress = now + self.sent = sent + + def progress_rate(self, now: float) -> float: + """Bytes per second since it started moving: a slow start inside the grace doesn't count against it.""" + if self.sent <= 0: + return 0.0 + since = self.first_progress if self.first_progress is not None else self.started + return self.sent / max(now - since, 1.0) + + def in_grace(self, now: float) -> bool: + """Whether it is still within its grace.""" + return now - self.grace_start <= self.grace + + def moving(self, now: float) -> bool: + """Whether it is making progress at or above its floor (always, if its progress can't be read).""" + return self.untracked or (self.sent > 0 and self.progress_rate(now) >= self.floor) + + def protected(self, now: float) -> bool: + """Whether no newcomer may displace it for making no progress: within its grace, or moving.""" + return self.in_grace(now) or self.moving(now) + class ResponsePools: """Slots for /blocks responses: max_responses // 2 for slow links, the rest for others. Thread-safe. - Each pool competes only with itself. When a pool is full, a new response displaces the pool's response - with the lowest throughput if that response is older than deadline_slack; otherwise it is refused. + Each pool competes only with itself. When a pool is full, a new response may displace (section 3), never + a response on its own link: + - the response making least progress among those past their grace and below their progress floor (it is + marked idle, and its requester cools down); + - else, if the newcomer's interface holds no slot in the pool, the oldest response with nothing sent from + an interface holding two or more, even within its grace; + - else the pool's response with the lowest throughput, if it is older than deadline_slack and past its grace. + Otherwise it is refused. """ def __init__(self, max_responses: int, deadline_slack: float, clock: Callable[[], float] = time.monotonic): @@ -139,22 +238,47 @@ def __init__(self, max_responses: int, deadline_slack: float, clock: Callable[[] self.active: dict[str, list[Response]] = {SLOW_POOL: [], FAST_POOL: []} self.lock = threading.Lock() - def reserve(self, pool: str, link_id: bytes, request_id: bytes) -> tuple[Response | None, Response | None]: - """Take a slot in pool: (new response, or None if refused; displaced response to cancel, or None).""" + def reserve(self, pool: str, link_id: bytes, request_id: bytes, grace: float | None = None, floor: float = 0.0, + interface=None) -> tuple[Response | None, Response | None]: + """Take a slot in pool: (new response, or None if refused; displaced response to cancel, or None). + + grace (default deadline_slack), floor and interface are the new response's (section 3). + """ with self.lock: now = self.clock() members = self.active[pool] displaced = None if len(members) >= self.capacity[pool]: - slowest = min(members, key=lambda r: r.throughput(now)) - if now - slowest.started <= self.deadline_slack: + displaced = self._displaceable(members, link_id, interface, now) + if displaced is None: return None, None - displaced = slowest members.remove(displaced) response = Response(link_id, request_id, pool, now) + response.grace = self.deadline_slack if grace is None else grace + response.floor = floor + response.interface = interface members.append(response) return response, displaced + def _displaceable(self, members: list[Response], link_id: bytes, interface, now: float) -> Response | None: + """The member a newcomer on link_id arriving on interface may displace, or None. Caller holds the lock.""" + others = [r for r in members if r.link_id != link_id] + idle = [r for r in others if not r.protected(now)] + if idle: + displaced = min(idle, key=lambda r: (r.progress_rate(now), r.sent, r.started)) + displaced.idle = True + return displaced + if interface is not None and all(r.interface is not interface for r in members): + held = Counter(id(r.interface) for r in members if r.interface is not None) + crowded = [r for r in others if r.interface is not None and held[id(r.interface)] >= 2 + and r.sent <= 0 and not r.untracked] + if crowded: + return min(crowded, key=lambda r: r.started) + slowest = min(members, key=lambda r: r.throughput(now)) + if slowest.link_id == link_id or now - slowest.started <= self.deadline_slack or slowest.in_grace(now): + return None + return slowest + def release(self, response: Response) -> bool: """Free response's slot. True if it held one.""" with self.lock: @@ -296,6 +420,47 @@ def in_progress(self, link_id: bytes) -> tuple[int, int]: return (state.requests, state.blocks) if state else (0, 0) +class Cooldowns: + """Links and identities refused /blocks for a while, after a response of theirs was displaced or cancelled + for making no progress (section 3). Thread-safe, and cheap to ask.""" + + def __init__(self, duration: float, clock: Callable[[], float] = time.monotonic): + """No one cooling down; each cooldown lasts duration seconds (0: none).""" + self.duration = duration + self.clock = clock + self.until: OrderedDict[bytes, float] = OrderedDict() # key -> end, soonest first (one duration for all) + self.lock = threading.Lock() + + def add(self, *keys: bytes | None): + """Start a cooldown for each key given (None is skipped).""" + if self.duration <= 0: + return + with self.lock: + now = self.clock() + for key in keys: + if key is not None: + self.until.pop(key, None) + self.until[key] = now + self.duration + self._prune(now) + + def active(self, *keys: bytes | None) -> bool: + """Whether any key given is cooling down.""" + with self.lock: + now = self.clock() + self._prune(now) + return any(key is not None and key in self.until for key in keys) + + def forget(self, key: bytes): + """Drop key (a closed link's ID: it never comes back).""" + with self.lock: + self.until.pop(key, None) + + def _prune(self, now: float): + """Drop ended cooldowns, and the oldest beyond COOLDOWN_MEMORY. Caller holds the lock.""" + while self.until and (len(self.until) > COOLDOWN_MEMORY or next(iter(self.until.values())) <= now): + self.until.popitem(last=False) + + class UploadPacer: """max_upload_rate between responses: a link's next response starts no sooner than bytes × 8 / cap seconds after the previous one started. Thread-safe.""" @@ -341,6 +506,7 @@ def __init__(self, destination: RNS.Destination, config: Config, backend: Backen self.pools = ResponsePools(config.max_blocks_responses, config.deadline_slack, clock) self.incoming = IncomingLinks(config.max_incoming_links, clock) self.pacer = UploadPacer(clock) + self.cooldowns = Cooldowns(config.blocks_cooldown, clock) # mesh: airtime controls on /blocks and the traffic counters (shared with the client by the node) self.airtime = mesh.AirtimePolicy(config, clock) self.traffic = traffic if traffic is not None else mesh.Traffic() @@ -439,6 +605,7 @@ def _link_closed(self, link): if response.link_id == link_id: self._finish(response) self.incoming.remove(link_id) + self.cooldowns.forget(link_id) self.pacer.forget(link_id) self.rates.forget(link_id) @@ -540,6 +707,10 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): link = self._find_link(link_id) if parsed is None or link is None: return False + identity = bytes(remote_identity.hash) if remote_identity is not None else None + if self.cooldowns.active(link_id, identity): + log("refused /blocks: that requester's last response made no progress", LOG_VERBOSE) + return False # mesh airtime controls: refused (False) at once, so the requester tries another provider refusal = self.airtime.refusal(mesh.interface_names(getattr(link, "attached_interface", None))) if refusal is not None: @@ -561,9 +732,18 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): return False rate = self.rates.get(link_id) slow = rates.link_is_slow(link, rate, self.config) - response, displaced = self.pools.reserve(SLOW_POOL if slow else FAST_POOL, link_id, request_id) + interface, bitrate, hops = link_path(link) + grace = response_grace(bitrate, hops, self.config) + # Until it is handed to RNS, the grace also covers the pacing wait + response, displaced = self.pools.reserve(SLOW_POOL if slow else FAST_POOL, link_id, request_id, + grace=min(self.config.deadline_slack, grace + wait), + floor=progress_floor(bitrate, hops, self.config), + interface=interface) + if response is not None: + response.identity = identity if displaced is not None: - self._cancel(displaced, "displaced by a new request") + self._cancel(displaced, "made no progress; displaced by a new request" if displaced.idle + else "displaced by a new request") if response is None: log(f"refused /blocks: every {'slow' if slow else 'fast'}-link response slot is busy", LOG_VERBOSE) return False @@ -575,7 +755,7 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): # The upload cap applies once the link has a rate estimate (section 3) cap = self.config.effective_max_upload_rate(slow, rate) if rate is not None else None limit = self.airtime.limit(blocks_budget(max_bytes, rate, cap, self.config.response_target)) - response.requester = bytes(remote_identity.hash) if remote_identity is not None else link_id + response.requester = identity if identity is not None else link_id share = self._budget_share_left(response) if share is not None: if share <= 0: @@ -611,6 +791,8 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): return body # nothing to track response.size = packed response.deadline = response.started + rates.response_deadline(packed, rate, self.config) + response.grace = grace + self._restart_grace(response) response.find_until = self.clock() + REGISTER_WINDOW response.untracked = link_id not in self.watched # From here the slots are freed when the Resource concludes, is cancelled, or the link closes @@ -708,10 +890,21 @@ def _registered(self, link, resource): request_id = getattr(resource, "request_id", None) for response in self.pools.responses(): if response.link_id == link.link_id and response.request_id == request_id and response.resource is None: - response.resource = resource - self._hook(response, link, resource) + self._attach(response, link, resource) return + def _attach(self, response: Response, link, resource): + """Follow resource, just advertised, as the response's: its grace starts again from here.""" + response.resource = resource + self._restart_grace(response) + self._hook(response, link, resource) + + def _restart_grace(self, response: Response): + """Start the response's grace (and its progress clock) now: it was just handed to RNS, or advertised.""" + now = self.clock() + response.grace_start = max(response.grace_start, now) + response.progress_at = max(response.progress_at, now) + def _finish(self, response: Response): """Free a response's pool slot and its link's /blocks slot, and settle its budget charge, once.""" with self.lock: @@ -775,6 +968,9 @@ def _cancel(self, response: Response, reason: str): if resource is None and link is not None and not response.untracked: resource = self._find_resource(link, response.request_id) log(f"cancelling a /blocks response: {reason}", LOG_VERBOSE) + if response.idle: + # Its requester made no progress with the slot: it can't take one again for a while (section 3) + self.cooldowns.add(response.link_id, response.identity) self._finish(response) if resource is not None and callable(getattr(resource, "cancel", None)): resource.cancel() @@ -832,8 +1028,7 @@ def check_responses(self) -> bool: # Normally the registration hook finds it; polling is a backup resource = self._find_resource(link, response.request_id) if resource is not None: - response.resource = resource - self._hook(response, link, resource) + self._attach(response, link, resource) elif now > response.find_until and not getattr(link, "outgoing_resources", None): # Never registered although nothing on the link is ahead of it: RNS failed to send it. # Its slots are only freed by closing the link, which certainly ends it. @@ -849,14 +1044,34 @@ def check_responses(self) -> bool: self._concluded(response, link, resource) continue try: - response.sent = resource.get_progress() * response.size + response.note_progress(resource.get_progress() * response.size, now) except Exception: - pass + resource = None # progress unknown: only the deadline applies + if resource is not None and self._check_progress(response, now): + continue if not response.finished and now > response.deadline: self._cancel(response, f"missed its deadline ({size(response.size)})") return searching + def _check_progress(self, response: Response, now: float) -> bool: + """Cancel (and refund) a response whose Resource makes no progress (section 3). True if cancelled. + + One with nothing sent is cancelled deadline_slack after its grace ended (by then a newcomer could have + displaced it anyway; this also gives its budget charge back when nobody else asks), and any response + with no progress for stall_timeout. Either way, its requester cools down unless it was moving. + """ + idle_for = now - response.progress_at + if response.sent <= 0 and now - response.grace_start > response.grace + self.config.deadline_slack: + reason = f"nothing sent {idle_for:.0f}s after it was advertised" + elif idle_for > self.config.stall_timeout: + reason = f"no progress for {idle_for:.0f}s" + else: + return False + response.idle = not response.moving(now) + self._cancel(response, reason) + return True + def _monitor_loop(self): """Run check_responses until stopped.""" while not self.stop_event.is_set(): diff --git a/tests/security/_server_helpers.py b/tests/security/_server_helpers.py new file mode 100644 index 0000000..a0a3b73 --- /dev/null +++ b/tests/security/_server_helpers.py @@ -0,0 +1,84 @@ +"""Helpers for the /blocks server security tests (H1, L2): fake links that know their RNS path, and a +RequestServer over tests/test_server.py's fakes driven by a FakeClock.""" +import RNS + +from rnfs.config import Config +from rnfs.constants import BLOCKS_PATH +from rnfs.network import protocol, server +from tests.support import FakeClock +from tests.test_server import FakeBackend, FakeDestination, FakeLink, FakeResource + +TCP_BITRATE = 10_000_000 # what RNS's TCP interfaces assume (TCPClientInterface.BITRATE_GUESS) +LORA_BITRATE = 1_000 # a slow LoRa channel + + +class FakeInterface: + """Stands in for an RNS Interface: a name and its configured bitrate.""" + + def __init__(self, name: str, bitrate): + """An interface called name, at bitrate bits/s.""" + self.name = name + self.bitrate = bitrate + + def __str__(self): + return self.name + + +class PathLink(FakeLink): + """A FakeLink that also has the interface its request arrived on and the requester's hop count.""" + + def __init__(self, link_id: bytes, interface=None, hops=1, **kwargs): + """A link over interface, hops hops away.""" + super().__init__(link_id, **kwargs) + self.attached_interface = interface + self.expected_hops = hops + + +class ServerHarness: + """A RequestServer over fake RNS objects and a FakeClock, with its monitor driven by hand.""" + + def __init__(self, blocks: dict, config: Config | None = None): + """Serve blocks (CID -> bytes) with config (default settings unless given).""" + self.clock = FakeClock() + self.backend = FakeBackend(blocks=blocks) + self.config = config or Config() + self.destination = FakeDestination() + saved = RNS.Resource.AUTO_COMPRESS_MAX_SIZE + self.server = server.RequestServer(self.destination, self.config, self.backend, clock=self.clock, + start_monitor=False) + RNS.Resource.AUTO_COMPRESS_MAX_SIZE = saved # process-wide; leave it as other tests expect + self.count = 0 + + def connect(self, interface=None, hops=1, mtu=500, mdu=431) -> PathLink: + """A new incoming link over interface, announced through the established callback.""" + self.count += 1 + link = PathLink(self.count.to_bytes(16, "big"), interface, hops, mtu=mtu, mdu=mdu) + self.destination.links.append(link) + self.destination.established(link) + return link + + def blocks(self, link, cids, max_bytes=64 * 1024, identity=None, request_id=None): + """Send a /blocks request on link as RNS would; the handler's answer.""" + handler = self.destination.handlers[BLOCKS_PATH][0] + request_id = request_id or link.link_id + return handler(BLOCKS_PATH, protocol.encode_blocks(max_bytes, cids), request_id, link.link_id, identity, + 0.0) + + @staticmethod + def advertise(link, request_id=None) -> FakeResource: + """RNS advertising the response Resource for request_id (default: the link's own ID) on link.""" + resource = FakeResource(link, request_id or link.link_id, status=RNS.Resource.ADVERTISED) + link.register_outgoing_resource(resource) + return resource + + def response(self, link): + """The response holding a slot for link, or None.""" + for response in self.server.pools.responses(): + if response.link_id == link.link_id: + return response + return None + + def tick(self, seconds: float = 0.0): + """Move the clock on and run one monitor pass.""" + self.clock.now += seconds + self.server.check_responses() diff --git a/tests/security/test_h1_response_slots.py b/tests/security/test_h1_response_slots.py new file mode 100644 index 0000000..47bf399 --- /dev/null +++ b/tests/security/test_h1_response_slots.py @@ -0,0 +1,388 @@ +"""H1: one peer could hold every /blocks response slot by never downloading its responses. + +A full pool used to protect any response younger than deadline_slack (30 s), even one with nothing sent, so a +requester re-requesting before then kept every slot. Now a response is protected only for a grace the node +measures from the interface and hop count its request came over, and after that only while it makes progress +at a floor; a requester whose response was displaced or cancelled for making no progress cools down, stalled +responses are cancelled and refunded, and a newcomer on an interface holding no slot may displace a response +that has sent nothing from an interface holding two. These tests drive the policy (ResponsePools) and the +RequestServer over fake RNS objects and a FakeClock. +""" +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE +from rnfs.network import server +from tests.security._server_helpers import LORA_BITRATE, TCP_BITRATE, FakeInterface, PathLink, ServerHarness +from tests.support import FakeClock +from tests.test_server import block_of + + +class GraceAndFloorTest(unittest.TestCase): + """The grace and the progress floor come from the node's own interface bitrate and the hop count.""" + + def setUp(self): + self.config = Config() + + def grace(self, bitrate, hops): + return server.response_grace(bitrate, hops, self.config) + + def test_grace_grows_with_hops_and_shrinks_with_bitrate(self): + self.assertLess(self.grace(LORA_BITRATE, 1), self.grace(LORA_BITRATE, 2)) + self.assertLess(self.grace(LORA_BITRATE, 2), self.grace(LORA_BITRATE, 3)) + self.assertGreater(self.grace(LORA_BITRATE, 2), self.grace(2 * LORA_BITRATE, 2)) + # A ~500-byte advertisement over 3 hops at 1 kbit/s: ~4 s per hop each way, so the first part request + # arrives after ~24 s, inside the grace + self.assertGreater(self.grace(LORA_BITRATE, 3), 24 + 2) + # Over TCP the hop count hardly matters: a few seconds + self.assertLess(self.grace(TCP_BITRATE, 1), 5) + self.assertLess(self.grace(TCP_BITRATE, 30), 5) + + def test_grace_is_clamped(self): + self.assertEqual(self.grace(LORA_BITRATE, 10), self.config.deadline_slack) + # The requester can raise its hop count; that never takes the grace past deadline_slack + self.assertEqual(self.grace(LORA_BITRATE, 255), self.config.deadline_slack) + self.assertEqual(self.grace(TCP_BITRATE, 255), self.grace(TCP_BITRATE, server.MAX_GRACE_HOPS)) + self.assertGreaterEqual(self.grace(1e12, 1), server.GRACE_BASE) + # An unknown bitrate is taken as unmeasured_bitrate: the old 30 s protection + self.assertEqual(self.grace(None, 1), self.config.deadline_slack) + self.assertEqual(self.grace(TCP_BITRATE, None), self.grace(TCP_BITRATE, 1)) + self.assertEqual(server.response_grace(TCP_BITRATE, 1, Config(deadline_slack=1)), 1) + + def test_floor(self): + minimum = self.config.unmeasured_bitrate / 8 + self.assertEqual(server.progress_floor(TCP_BITRATE, 1, self.config), TCP_BITRATE / 1000 / 8) + # Behind more hops the path may be far slower than our interface: only the minimum applies + self.assertEqual(server.progress_floor(TCP_BITRATE, 2, self.config), minimum) + self.assertEqual(server.progress_floor(LORA_BITRATE, 1, self.config), minimum) + self.assertEqual(server.progress_floor(None, 1, self.config), minimum) + + def test_link_path_reads_rns_attributes_defensively(self): + lora = FakeInterface("LoRa", LORA_BITRATE) + self.assertEqual(server.link_path(PathLink(b"a" * 16, lora, 3)), (lora, LORA_BITRATE, 3)) + self.assertEqual(server.link_path(object()), (None, None, None)) + for bitrate in (None, 0, -5, True, "fast", float("nan"), float("inf")): + self.assertIsNone(server.link_path(PathLink(b"a" * 16, FakeInterface("x", bitrate)))[1], bitrate) + for hops in (None, -1, True, "3", 2.0): + self.assertIsNone(server.link_path(PathLink(b"a" * 16, lora, hops))[2], hops) + + +class ResponsePoolsPolicyTest(unittest.TestCase): + """Displacement in a full pool: protection is earned by progress.""" + + GRACE = 5.0 + FLOOR = 100.0 # bytes/s + + def setUp(self): + """Pools of 4 slots (2 per pool) with 30 s slack.""" + self.clock = FakeClock() + self.pools = server.ResponsePools(4, 30, self.clock) + self.hub = FakeInterface("hub", TCP_BITRATE) + + def take(self, name: bytes, interface=None): + """Reserve a fast-pool slot for link name.""" + return self.pools.reserve(server.FAST_POOL, name, name, grace=self.GRACE, floor=self.FLOOR, + interface=interface if interface is not None else self.hub) + + def move(self, seconds: float, *moving): + """Each (response, rate) in moving sends at rate bytes/s for seconds, as the monitor reads it each second.""" + for _ in range(int(seconds)): + self.clock.now += 1 + for response, rate in moving: + response.note_progress(response.sent + rate, self.clock.now) + + def test_zero_progress_becomes_displaceable_after_its_grace_not_before(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.clock.now += self.GRACE + self.assertEqual(self.take(b"c"), (None, None)) + self.clock.now += 0.1 + response, displaced = self.take(b"c") + self.assertIsNotNone(response) + self.assertIn(displaced, (a, b)) + self.assertTrue(displaced.idle) # its requester cools down + + def test_moving_response_keeps_its_slot_after_grace(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.move(10, (a, 2 * self.FLOOR)) + response, displaced = self.take(b"c") + self.assertIs(displaced, b) # nothing sent: displaced; a is moving + for i in range(20): # well past both graces, but not older than deadline_slack + self.move(1, (a, 2 * self.FLOOR), (response, self.FLOOR)) + self.assertEqual(self.take(bytes([i]) * 4), (None, None), i) + self.assertEqual(set(self.pools.responses()), {a, response}) + + def test_trickle_below_the_floor_is_not_protected(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.move(10, (a, 2 * self.FLOOR), (b, self.FLOOR / 10)) # b sends something, but far below the floor + self.assertIs(self.take(b"c")[1], b) + + def test_least_progress_is_displaced_first(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.clock.now += self.GRACE + 1 + a.note_progress(1, self.clock.now) # a byte: still below the floor, but more than b + self.assertIs(self.take(b"c")[1], b) + + def test_link_cannot_displace_its_own_response(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.move(10, (b, 2 * self.FLOOR)) + # a has sent nothing and is past its grace, but only another link may take its slot + self.assertEqual(self.take(b"a"), (None, None)) + self.assertIs(self.take(b"c")[1], a) + + def test_moving_responses_follow_the_old_slowest_rule(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.move(30, (a, 4 * self.FLOOR), (b, 2 * self.FLOOR)) + self.assertEqual(self.take(b"c"), (None, None)) # both moving, not older than deadline_slack + self.move(1, (a, 4 * self.FLOOR), (b, 2 * self.FLOOR)) + response, displaced = self.take(b"c") + self.assertIs(displaced, b) + self.assertFalse(displaced.idle) # it was moving: no cooldown + + def test_newcomer_from_an_unrepresented_interface_takes_a_slot_from_a_crowded_one(self): + other = FakeInterface("radio", LORA_BITRATE) + a, _ = self.take(b"a") + self.clock.now += 1 + b, _ = self.take(b"b") + # Both within their grace and nothing sent, both from the hub: a newcomer from the hub is refused + self.assertEqual(self.take(b"c"), (None, None)) + response, displaced = self.take(b"d", other) + self.assertIsNotNone(response) + self.assertIs(displaced, a) # the oldest + self.assertFalse(displaced.idle) # displaced within its grace: no cooldown + # Now each interface holds a slot: nobody else gets in during the grace + self.assertEqual(self.take(b"e", other), (None, None)) + self.assertEqual(self.take(b"f", FakeInterface("third", TCP_BITRATE)), (None, None)) + self.assertEqual(set(self.pools.responses()), {b, response}) + + def test_crowded_interface_keeps_responses_that_are_moving(self): + a, _ = self.take(b"a") + b, _ = self.take(b"b") + self.clock.now += 1 + a.note_progress(1000, self.clock.now) + b.note_progress(1000, self.clock.now) + self.assertEqual(self.take(b"c", FakeInterface("radio", LORA_BITRATE)), (None, None)) + + +class RequestServerSlotsTest(unittest.TestCase): + """The policy wired to RNS (faked): grace from the link's path, cooldowns, stall cancellation.""" + + def setUp(self): + (self.cid, self.block), (self.cid2, self.block2) = block_of(CHUNK_SIZE, 1), block_of(CHUNK_SIZE, 2) + self.hub = FakeInterface("TCP hub", TCP_BITRATE) + + def harness(self, config=None) -> ServerHarness: + h = ServerHarness({self.cid: self.block, self.cid2: self.block2}, config) + self.server = h.server + return h + + def tcp(self, h: ServerHarness, hops=1, interface=None) -> PathLink: + """A fast-pool link over the TCP hub.""" + return h.connect(interface or self.hub, hops, mtu=1500, mdu=1400) + + def take(self, h, link, identity=None): + """A /blocks request on link that must be accepted, and its Resource advertised.""" + self.assertIsNot(h.blocks(link, [self.cid], identity=identity), False) + return h.advertise(link) + + def test_honest_slow_multi_hop_transfer_keeps_its_slot_and_completes(self): + """~1 kbit/s, 3 hops: the first part request arrives ~24 s after the advertisement. Meanwhile and + afterwards newcomers on the same interface ask every 2 s and never download anything.""" + h = self.harness() + lora = FakeInterface("LoRa", LORA_BITRATE) + honest = h.connect(lora, hops=3) + resource = self.take(h, honest) + response = h.response(honest) + self.assertEqual(response.pool, server.SLOW_POOL) + self.assertGreater(response.grace, 24) + rate = LORA_BITRATE / 8 * 0.8 # what the channel really delivers + newcomers = [] + t = 0 + while resource.status != RNS.Resource.COMPLETE: + self.assertLess(t, 2000, "the honest transfer never completed") + t += 1 + h.clock.now += 1 + if t >= 24: # its first part request has arrived: parts flow at the channel's rate + resource.status = RNS.Resource.TRANSFERRING + resource.progress = min(1.0, (t - 24) * rate / response.size) + if resource.progress >= 1.0: + resource.complete() + h.server.check_responses() + self.assertFalse(resource.cancelled, f"the honest response was cancelled at {t}s") + if t % 2 == 0: + link = h.connect(lora, hops=1) + if h.blocks(link, [self.cid2]) is not False: + newcomers.append(h.advertise(link)) + if resource.status != RNS.Resource.COMPLETE: + self.assertIs(h.response(honest), response, f"the honest response lost its slot at {t}s") + self.assertIsNone(h.response(honest)) + self.assertGreater(t, 500) # it really did run for many minutes alongside the newcomers + # The newcomers did take the other slot in turn, each losing it for making no progress + self.assertGreater(len(newcomers), 20) + self.assertGreater(sum(1 for r in newcomers if r.cancelled), 20) + + def test_zero_progress_response_is_displaced_after_its_grace_and_its_link_cools_down(self): + h = self.harness() + a, b = self.tcp(h), self.tcp(h) + held_a, held_b = self.take(h, a), self.take(h, b) + grace = h.response(a).grace + self.assertLess(grace, 5) + h.tick(grace - 0.01) + c = self.tcp(h) + self.assertIs(h.blocks(c, [self.cid]), False) # both still within their grace + h.tick(0.02) + self.assertIsNot(h.blocks(c, [self.cid]), False) + displaced, kept = (a, b) if held_a.cancelled else (b, a) + self.assertTrue((held_a if displaced is a else held_b).cancelled) + self.assertIsNotNone(h.response(kept)) + self.assertIsNone(h.response(displaced)) + # The displaced link is refused while it cools down, even with a slot free + h.server._cancel(h.response(kept), "test: free a slot") + self.assertEqual(h.server.pools.count(server.FAST_POOL), 1) + self.assertIs(h.blocks(displaced, [self.cid]), False) + self.assertEqual(h.server.pools.count(server.FAST_POOL), 1) + h.tick(h.config.blocks_cooldown - 1) + self.assertIs(h.blocks(displaced, [self.cid]), False) + h.tick(1.1) + self.assertIsNot(h.blocks(displaced, [self.cid]), False) + + def test_identity_cools_down_with_its_link(self): + h = self.harness() + identity = RNS.Identity() + a = self.tcp(h) + self.take(h, a, identity) + self.take(h, self.tcp(h)) + h.tick(h.response(a).grace + 0.1) + h.server.pools.responses()[1].note_progress(10_000, h.clock.now) # the other one is moving + self.assertIsNot(h.blocks(self.tcp(h), [self.cid]), False) # displaces a + self.assertIsNone(h.response(a)) + self.assertTrue(h.server.cooldowns.active(a.link_id, bytes(identity.hash))) + h.server.pools.active[server.FAST_POOL].clear() # free every slot + self.assertIs(h.blocks(self.tcp(h), [self.cid], identity=identity), False) # a new link, same identity + self.assertIsNot(h.blocks(self.tcp(h), [self.cid], identity=RNS.Identity()), False) + + def test_cooldown_zero_turns_it_off(self): + h = self.harness(Config(blocks_cooldown=0)) + a = self.tcp(h) + self.take(h, a) + self.take(h, self.tcp(h)) + h.tick(5) + self.assertIsNot(h.blocks(self.tcp(h), [self.cid]), False) + self.assertFalse(h.server.cooldowns.active(a.link_id)) + + def refunds(self, h: ServerHarness) -> list[int]: + """The amounts given back to upload_budget from now on.""" + refunds = [] + refund = h.server.airtime.refund + h.server.airtime.refund = lambda amount: refunds.append(amount) or refund(amount) + return refunds + + def test_zero_progress_response_is_cancelled_and_refunded_without_newcomers(self): + h = self.harness(Config(upload_budget=1000 * 1024)) + refunds = self.refunds(h) + a = self.tcp(h) + resource = self.take(h, a) + response = h.response(a) + charged = response.charged + self.assertGreater(charged, CHUNK_SIZE) + h.tick(response.grace + h.config.deadline_slack - 0.01) + self.assertFalse(resource.cancelled) + h.tick(0.5) + self.assertTrue(resource.cancelled) + self.assertIsNone(h.response(a)) + self.assertEqual(refunds, [charged]) # nothing was sent: all of it + self.assertTrue(h.server.cooldowns.active(a.link_id)) + + def test_stalled_response_is_cancelled_and_refunded(self): + h = self.harness(Config(upload_budget=1000 * 1024)) + refunds = self.refunds(h) + a = self.tcp(h, hops=2) + resource = self.take(h, a) + resource.status = RNS.Resource.TRANSFERRING + response = h.response(a) + charged = response.charged + for second in range(1, 11): + resource.progress = 0.03 * second + h.tick(1) + h.tick(h.config.stall_timeout) + self.assertFalse(resource.cancelled) + h.tick(1) + self.assertTrue(resource.cancelled) + self.assertIsNone(h.response(a)) + self.assertEqual(len(refunds), 1) + self.assertAlmostEqual(refunds[0], charged - 0.3 * response.size, delta=2) # what wasn't sent + self.assertFalse(h.server.cooldowns.active(a.link_id)) # it was moving well above its floor + + def test_complete_resource_is_concluded_not_cancelled(self): + """RNS's Resource.cancel() does nothing once a Resource is COMPLETE; a response whose Resource completed + between two monitor passes, with no progress seen, is concluded, not cancelled.""" + h = self.harness() + a = self.tcp(h) + resource = self.take(h, a) + resource.status = RNS.Resource.COMPLETE + h.tick(h.response(a).grace + h.config.deadline_slack + 1) + self.assertFalse(resource.cancelled) + self.assertIsNone(h.response(a)) + self.assertFalse(h.server.cooldowns.active(a.link_id)) + + def test_newcomer_on_another_interface_is_served_while_one_interface_holds_both_slots(self): + h = self.harness() + a, b = self.tcp(h), self.tcp(h) + held_a = self.take(h, a) + h.tick(1) + self.take(h, b) + self.assertIs(h.blocks(self.tcp(h), [self.cid]), False) # same interface: refused during the grace + other = self.tcp(h, interface=FakeInterface("TCP to a second hub", TCP_BITRATE)) + self.assertIsNot(h.blocks(other, [self.cid]), False) + self.assertTrue(held_a.cancelled) # the oldest response from the crowded interface + self.assertFalse(h.server.cooldowns.active(a.link_id)) # it was still within its grace + self.assertIsNotNone(h.response(b)) + + def test_cooldown_refusal_is_cheap(self): + """A link cooling down is refused before any slot is reserved or any block is read.""" + h = self.harness() + a = self.tcp(h) + h.server.cooldowns.add(a.link_id) + reads = [] + served = h.backend.served_block + h.backend.served_block = lambda cid: reads.append(cid) or served(cid) + self.assertIs(h.blocks(a, [self.cid]), False) + self.assertEqual(reads, []) + self.assertEqual(h.server.pools.responses(), []) + self.assertEqual(h.server.incoming.in_progress(a.link_id), (0, 0)) + + def test_closed_link_cooldown_is_forgotten(self): + h = self.harness() + a = self.tcp(h) + h.server.cooldowns.add(a.link_id) + a.teardown() + self.assertNotIn(a.link_id, h.server.cooldowns.until) + + +class CooldownsTest(unittest.TestCase): + """The cooldown table: expiry and a bounded memory.""" + + def test_expiry_and_bound(self): + clock = FakeClock() + cooldowns = server.Cooldowns(60, clock) + cooldowns.add(b"a", None) + clock.now += 59 + self.assertTrue(cooldowns.active(b"x", b"a")) + clock.now += 1 + self.assertFalse(cooldowns.active(b"a")) + for i in range(server.COOLDOWN_MEMORY + 10): + cooldowns.add(i.to_bytes(4, "big")) + self.assertEqual(len(cooldowns.until), server.COOLDOWN_MEMORY) + self.assertFalse(cooldowns.active((0).to_bytes(4, "big"))) + self.assertTrue(cooldowns.active((server.COOLDOWN_MEMORY + 9).to_bytes(4, "big"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_rns_compat_security.py b/tests/security/test_rns_compat_security.py new file mode 100644 index 0000000..e2935bb --- /dev/null +++ b/tests/security/test_rns_compat_security.py @@ -0,0 +1,94 @@ +"""The RNS internals the /blocks slot fairness fix (H1) relies on are still present in the installed RNS. + +server.py's displacement protection measures a per-response grace from the receiving interface's bitrate and +the requester's hop count, read through server.link_path(). Those are RNS object attributes that the version +number doesn't promise to keep, so, like tests/test_rns_compat.py (another session's file, not edited here), +this checks each one and fails naming what changed rather than letting the grace silently fall back. + +Written against RNS 1.5.6. Nothing here starts Reticulum. +""" +import ast +import inspect +import re +import textwrap +import unittest + +import RNS +from RNS.Interfaces.Interface import Interface + +from rnfs.config import Config +from rnfs.network import server + + +def _normal(code: str) -> str: + """code without whitespace and with single quotes, so source matches however it's laid out.""" + return re.sub(r"\s+", "", code).replace('"', "'") + + +def _source_assigns(function, attribute: str, value: str) -> bool: + """Whether function assigns self. = (matched as source, ignoring layout).""" + tree = ast.parse(textwrap.dedent(inspect.getsource(function))) + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) \ + and target.value.id == "self" and target.attr == attribute \ + and _normal(ast.unparse(node.value)) == _normal(value): + return True + return False + + +class RnsSlotFairnessInternalsTest(unittest.TestCase): + """Each RNS attribute server.link_path() reads is present and set where rnfs expects.""" + + def test_link_has_attached_interface_set_from_the_request_packet(self): + # server.link_path reads Link.attached_interface; RNS sets it on __init__ and from the link request + self.assertIn("attached_interface", inspect.getsource(RNS.Link.__init__)) + self.assertIn("attached_interface = packet.receiving_interface", + _source_or_empty(RNS.Link.validate_request)) + + def test_link_has_expected_hops_set_from_the_rtt_packet(self): + # server.link_path reads Link.expected_hops; RNS sets it on __init__ and from the RTT packet's hop count + self.assertIn("expected_hops", inspect.getsource(RNS.Link.__init__)) + self.assertIn("self.expected_hops = packet.hops", inspect.getsource(RNS.Link.rtt_packet)) + + def test_receiving_interface_hop_count_increases_on_each_interface(self): + # expected_hops comes from packet.hops, which Transport increments as a packet crosses interfaces, so a + # requester can only inflate it; link_path and response_grace clamp it to MAX_GRACE_HOPS either way + self.assertIn("packet.hops += 1", inspect.getsource(RNS.Transport.preprocess_inbound)) + + def test_interface_has_a_bitrate(self): + # server.link_path reads interface.bitrate (node-local config, or the interface's own guess) + self.assertIn("self.bitrate", inspect.getsource(Interface.__init__)) + interface = Interface.__new__(Interface) + interface.bitrate = 1_000_000 + self.assertEqual(getattr(interface, "bitrate", None), 1_000_000) + + def test_link_path_reads_them_and_falls_back_when_missing(self): + config = Config() + + class _Interface: + bitrate = 1_000 + + class _Link: + attached_interface = _Interface() + expected_hops = 3 + + self.assertEqual(server.link_path(_Link()), (_Link.attached_interface, 1_000, 3)) + # A changed RNS (attributes gone) falls back to the conservative unknowns, never crashing + interface, bitrate, hops = server.link_path(object()) + self.assertEqual((interface, bitrate, hops), (None, None, None)) + self.assertEqual(server.response_grace(None, None, config), config.deadline_slack) + self.assertEqual(server.progress_floor(None, None, config), config.unmeasured_bitrate / 8) + + +def _source_or_empty(function) -> str: + """function's source, or "" if it can't be read.""" + try: + return inspect.getsource(function) + except (OSError, TypeError): + return "" + + +if __name__ == "__main__": + unittest.main() From 80b950d45b7766a8e340d9ec298f81a53feb0409 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:45:54 +1030 Subject: [PATCH 025/107] Refuse /blocks requests that repeat a CID --- rnfs/network/protocol.py | 9 +- .../security/test_l2_blocks_request_limits.py | 82 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/security/test_l2_blocks_request_limits.py diff --git a/rnfs/network/protocol.py b/rnfs/network/protocol.py index 4d9be40..da4a3f8 100644 --- a/rnfs/network/protocol.py +++ b/rnfs/network/protocol.py @@ -157,14 +157,19 @@ def encode_blocks(max_bytes: int, cids: list[Cid]) -> list: def parse_blocks_request(data) -> tuple[int, list[Cid]] | None: - """(max_bytes, CIDs) from a /blocks request, or None if malformed.""" + """(max_bytes, CIDs) from a /blocks request, or None if malformed. + + A request that repeats a CID is malformed: honest requesters ask for each CID once (the client drops + duplicates), so a duplicate is only ever an attempt to make the node read and serve the same block several + times in one response (specification section 7). + """ if type(data) is not list or len(data) != 2: return None max_bytes, binaries = data if not is_count(max_bytes) or type(binaries) is not list: return None cids = [parse_cid(binary) for binary in binaries] - if any(cid is None for cid in cids): + if any(cid is None for cid in cids) or len(set(cids)) != len(cids): return None return max_bytes, cids diff --git a/tests/security/test_l2_blocks_request_limits.py b/tests/security/test_l2_blocks_request_limits.py new file mode 100644 index 0000000..0a70ca8 --- /dev/null +++ b/tests/security/test_l2_blocks_request_limits.py @@ -0,0 +1,82 @@ +"""L2: the /blocks server used to serve duplicate CIDs and, on a link with no rate estimate, cap a response +only at the requester's max_bytes (up to ~1 MiB). + +Duplicate CIDs: FIXED here. A request that repeats a CID is refused as malformed, so a node can't be made to +read and serve the same block several times in one response. + +The unmeasured-link budget cap (at CHUNK_SIZE) is NOT applied, because doing so changes behaviour that four +tests in tests/test_server.py (another session's file) assert, and those must keep passing. The cap tests below +assert the safe (capped) behaviour and are @unittest.expectedFailure until the cap lands together with those +tests' updates (the exact diffs are in the handback report). +""" +import unittest + +from rnfs.constants import CHUNK_SIZE, KIB, MAX_BLOCK_SIZE, MAX_RESPONSE_SIZE +from rnfs.network import protocol, server +from tests.security._server_helpers import TCP_BITRATE, FakeInterface, ServerHarness +from tests.test_server import block_of + + +class ParseDuplicatesTest(unittest.TestCase): + """parse_blocks_request refuses a request that repeats a CID (fixed).""" + + def test_duplicate_cids_are_refused(self): + (c1, _), (c2, _) = block_of(10, 1), block_of(20, 2) + self.assertIsNotNone(protocol.parse_blocks_request(protocol.encode_blocks(1000, [c1, c2]))) + self.assertIsNone(protocol.parse_blocks_request(protocol.encode_blocks(1000, [c1, c1]))) + self.assertIsNone(protocol.parse_blocks_request(protocol.encode_blocks(1000, [c1, c2, c1]))) + + def test_server_refuses_a_duplicate_request(self): + (c1, b1), (c2, b2) = block_of(CHUNK_SIZE, 1), block_of(CHUNK_SIZE, 2) + h = ServerHarness({c1: b1, c2: b2}) + link = h.connect(FakeInterface("hub", TCP_BITRATE), mtu=1500, mdu=1400) + self.assertIs(h.blocks(link, [c1, c1]), False) + self.assertEqual(h.server.pools.responses(), []) + self.assertEqual(h.blocks(link, [c1, c2], max_bytes=MAX_RESPONSE_SIZE), [b1, b2]) # distinct served + + +class MeasuredBudgetTest(unittest.TestCase): + """With a rate estimate the budget is unchanged (this already holds).""" + + def test_budget_with_a_rate_estimate_is_unchanged(self): + self.assertEqual(server.blocks_budget(1_000_000, 8000, None, 60), 60_000) + self.assertEqual(server.blocks_budget(1_000_000, 8000, 4000, 60), 30_000) + self.assertEqual(server.blocks_budget(5000, 1e9, None, 60), 5000) + + def test_measured_link_serves_more_than_one_block(self): + blocks = {cid: data for cid, data in (block_of(CHUNK_SIZE, i) for i in range(10))} + h = ServerHarness(blocks) + link = h.connect(FakeInterface("hub", TCP_BITRATE), mtu=1500, mdu=1400) + h.server.rates.record(link.link_id, 64 * KIB, 8000 * 8) # 8 kB/s: 480 kB in 60 s + served = [e for e in h.blocks(link, list(blocks), max_bytes=MAX_RESPONSE_SIZE) if e is not None] + self.assertGreater(len(served), 1) + + +class UnmeasuredBudgetCapTest(unittest.TestCase): + """The unmeasured-link budget cap at CHUNK_SIZE: the safe behaviour, not yet applied (see module docstring).""" + + # L2: fails until blocks_budget caps an unmeasured link at CHUNK_SIZE (and tests/test_server.py is updated) + @unittest.expectedFailure + def test_budget_without_a_rate_estimate_is_capped_at_chunk_size(self): + self.assertEqual(server.blocks_budget(10 * CHUNK_SIZE, None, None, 60), CHUNK_SIZE) + self.assertEqual(server.blocks_budget(MAX_RESPONSE_SIZE, None, None, 60), CHUNK_SIZE) + + # L2: fails until an unmeasured link's response is capped at CHUNK_SIZE + @unittest.expectedFailure + def test_unmeasured_link_response_is_small(self): + blocks = {cid: data for cid, data in (block_of(CHUNK_SIZE, i) for i in range(10))} + h = ServerHarness(blocks) + link = h.connect(FakeInterface("hub", TCP_BITRATE), mtu=1500, mdu=1400) + served = [e for e in h.blocks(link, list(blocks), max_bytes=MAX_RESPONSE_SIZE) if e is not None] + self.assertEqual(len(served), 1) # one CHUNK_SIZE block, not ten + + def test_first_block_is_still_served_whole_regardless(self): + # The first served block is always included, up to MAX_BLOCK_SIZE, whether or not the cap applies + (c1, b1), (c2, b2) = block_of(MAX_BLOCK_SIZE, 1), block_of(CHUNK_SIZE, 2) + h = ServerHarness({c1: b1, c2: b2}) + link = h.connect(FakeInterface("hub", TCP_BITRATE), mtu=1500, mdu=1400) + self.assertEqual(h.blocks(link, [c1], max_bytes=CHUNK_SIZE)[0], b1) + + +if __name__ == "__main__": + unittest.main() From 7b9f6b9bee6f0c0acf21e476d9912d0890fa5c8a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:45:54 +1030 Subject: [PATCH 026/107] Drop expectedFailure from the real-RNS H1 slot-hold test --- tests/security/test_h1_real_rns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/security/test_h1_real_rns.py b/tests/security/test_h1_real_rns.py index 024b19f..e150f6d 100644 --- a/tests/security/test_h1_real_rns.py +++ b/tests/security/test_h1_real_rns.py @@ -113,7 +113,6 @@ def _honest(self, name, duration, interval=1.0): with open(out) as f: return json.load(f) - @unittest.expectedFailure # H1: fails until the victim stops letting one peer hold every /blocks slot def test_one_peer_denies_the_blocks_service(self): # Baseline: honest alone is always served. baseline = self._honest("honest_baseline", 6) From a22bc1b74da14658613cd872acd59a66fd7f1318 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:45:54 +1030 Subject: [PATCH 027/107] Document the /blocks slot fairness fix in spec, design and changelog --- CHANGELOG.md | 15 +++++++++++++++ README.md | 1 + design.md | 9 +++++++++ specification.md | 45 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9f83a..71ab636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ [specification.md](specification.md) is the authority on behaviour. +## Unreleased + +Serving `/blocks` under load: +- Displacement protection is now earned by progress, so one peer can no longer hold every `/blocks` response + slot almost for free. A response is protected from displacement only while it is within a short grace the + node measures from the receiving interface's bitrate and the request's hop count (never from the + requester-controlled `link.rtt`), or while it is moving data at or above a throughput floor. A response that + transfers nothing is cancelled and its upload-budget charge refunded once its grace passes, and any response + with no progress for `stall_timeout` is cancelled too. +- A link (and its identity, if it identified) whose response was displaced or cancelled for making no progress + is refused new `/blocks` for `blocks_cooldown` (new setting, default 60 s). A newcomer on an interface that + holds no slot in a full pool may displace a zero-progress response from an interface holding two or more. +- A `/blocks` request that repeats a CID is refused as malformed, so a node can't be made to read and serve + the same block several times in one response. + ## 0.1.3 Joining the DHT: diff --git a/README.md b/README.md index b5212c5..eb36f59 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,7 @@ lxmf_auto_fetch_from = [""] | `response_target` | `60` | Transfer time each block response aims for | | `deadline_slack` | `30` | Added to response deadlines | | `stall_timeout` | `120` | A response with no progress for this long is abandoned | +| `blocks_cooldown` | `60` | A link whose response made no progress is refused `/blocks` this long | | `max_blocks_responses` | `4` | Uploads in progress at once (half reserved for slow links; ≥ 2) | | `max_upload_rate` | none; half the measured rate on slow links | Upload cap per link (bits/s) | | `max_jobs` | 2; 1 on slow nodes | Transfers (`get`, `pin add`, `export`) running at once; set 1 on a slow radio | diff --git a/design.md b/design.md index 8369bd3..ae4d092 100644 --- a/design.md +++ b/design.md @@ -288,6 +288,15 @@ injected clocks); and `Traffic`, thread-safe counters per peer (LRU, at most 100 and in total, never persisted. - `Fetcher.path_info` (default: nothing known) is set by the node to `RnsPaths.info`; `Fetch._rank` orders both providers and open-link probes, and each provider caches its `PathInfo`. +- `ResponsePools` protects a response from displacement only while it is within a grace it measures from the + link's interface bitrate and hop count (`link_path`, `response_grace`, never from `link.rtt`), or moving at + or above a floor (`progress_floor`). A full pool displaces the least-progressing unprotected response (else + an interface-fairness pick: a zero-progress response from an interface holding two or more slots, for a + newcomer whose interface holds none; else the old slowest-past-`deadline_slack` rule), never one on the + newcomer's own link. `check_responses` cancels and refunds a response that has sent nothing once its grace + plus `deadline_slack` has passed, or that makes no progress for `stall_timeout`. `Cooldowns` then refuses + `/blocks` from a link (and its identity, if identified) whose response was displaced or cancelled for making + no progress, for `blocks_cooldown`, cheaply and before any slot is taken. - `RequestServer.airtime` checks interface rules, quiet hours and an empty budget before taking any slot, caps the block budget, and charges the bucket once the response's size is known. Each `Response` records its charge and its requester (identity hash, else link ID); one requester's unsettled charges may reach at diff --git a/specification.md b/specification.md index 1d4fb75..1ac4665 100644 --- a/specification.md +++ b/specification.md @@ -135,12 +135,29 @@ pass a `max_response_size` just above their largest honest answer. pool competes only with itself, so neither kind of client can crowd out the other. - A response's **deadline** is 3 × its size at the link's rate estimate (at `unmeasured_bitrate` if it has none), plus `deadline_slack`. A response not finished by then is cancelled. - - When a pool is full, a new `/blocks` request for it displaces that pool's in-progress response with the - lowest throughput, if that response is older than `deadline_slack`; otherwise the new request is refused. + - **Displacement protection is earned by progress**, so a response that holds a slot without transferring + can't keep honest requests out. A response is protected from displacement only while it is within a + per-response **grace** the node measures itself, or while it is moving data at or above a **throughput + floor**. The grace is `base + 2 × hops × advertisement_bits / bitrate`, clamped to + `[grace_min, deadline_slack]`, where `bitrate` is the receiving interface's configured bitrate + (`Link.attached_interface`'s `bitrate`, `unmeasured_bitrate` if unknown) and `hops` is the request's hop + count (`Link.expected_hops`, clamped, since a requester can inflate it). It is never derived from + `link.rtt`, which the requester controls. The floor is `unmeasured_bitrate / 8` bytes/s, or a small + fraction of the interface's bitrate for a directly connected requester. + - When a pool is full, a new `/blocks` request displaces one of the pool's responses, never one on its own + link: the response making least progress among those that are unprotected (past their grace and below the + floor); else, if the newcomer's receiving interface holds no slot in the pool, the oldest response with + nothing sent from an interface that holds two or more; else the response with the lowest throughput, if it + is older than `deadline_slack` and past its grace. Otherwise the request is refused. + - A response that makes **no progress** is cancelled and its budget charge refunded: one with nothing sent, + once its grace plus `deadline_slack` has passed; any response with no progress for `stall_timeout`. + - **Cooldown.** A link (and its identity, if it identified) whose response was displaced or cancelled for + making no progress is refused new `/blocks` for `blocks_cooldown`. The refusal is answered at once, before + any slot is reserved or block read. - A handler gets no direct handle on the response Resource RNS sends for it. Responses are tracked through the link's outgoing Resources (`Link.outgoing_resources`, matched by `request_id`) and cancelled with - `Resource.cancel()`. These are RNS internals that may change between versions; if they're unavailable, a - response is cancelled by closing its link. + `Resource.cancel()` (which acts only while the Resource is not yet complete). These are RNS internals that + may change between versions; if they're unavailable, a response is cancelled by closing its link. - At most `max_incoming_links` incoming links. When full, one is closed to make room: the link idle longest with no request in progress, or else a random one with no `/blocks` response in progress. In private mode, links not identified as a member's are chosen first. @@ -493,7 +510,9 @@ and repeat. ## 7. Transfer **`/blocks`.** -- **Request:** `[max_bytes, [cid, …]]`, with as many CIDs as fit in one packet. +- **Request:** `[max_bytes, [cid, …]]`, with as many CIDs as fit in one packet. The CIDs MUST be distinct; a + request that repeats a CID is malformed and refused, so a node can't be made to read and serve the same + block several times in one response. - **Response:** a list in request order, one entry per CID until the budget is reached. Each entry is the block, or `None` if the node doesn't hold it or doesn't serve it. Entries after the last returned were not sent because of the budget. @@ -727,7 +746,7 @@ as is cheap. Rules live in the sections referenced. | Hiding content near a key | Provider search doesn't stop at the first reply that lists providers (6) | | Routing table takeover with new identities | Only proven-reachable nodes stored; full buckets keep long-lived nodes (6) | | Fake node IDs in replies | Unknown IDs never stored; at most `alpha` path requests for them per lookup; lookup time budget (6) | -| Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement of the slowest response within separate slow and fast pools; requester stall detection (2, 3, 7) | +| Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement protection earned by progress (a per-response grace, then a throughput floor), so a response that transfers nothing can't hold a slot; no-progress and stall cancellation with refund; a cooldown on a link whose response made no progress; requester stall detection (2, 3, 7) | | Request flooding, link exhaustion | Request size, per-link and global limits; eviction of idle incoming links (3) | | Decompression and oversized-response bombs | Compressed Resources refused; response sizes capped; multi-segment Resources refused on `rnfs.dht` links and on links to LXMF propagation nodes (3, 7, 13) | | Reading another node's download history | Only provided content is served (7). Under the default `roots`, complete cached downloads are provided; `pinned` keeps them private | @@ -788,6 +807,13 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. link's rate estimate at the responder; the effect is limited to that link. - An attacker that behaves exactly like a slow-link client competes fairly for the slow pool's slots, and can't be told apart from one. +- Displacement protection and the cooldown stop a requester holding a response slot without transferring, and + the cooldown binds a link and its identity. A flood of fresh unidentified links that each take a slot, + transfer nothing, and are replaced before their cooldown matters can still keep a pool busy; each counts as + its own requester, so the cooldown doesn't bind them. Where all such links arrive over one interface + (a single upstream transport node), the per-interface rule can't separate them from honest requesters on + the same interface either. The hold is no longer free — the attacker must keep opening links, which incoming + link admission and RNS link establishment bound — but the fast pool can still be denied this way. - An outside web site can frame a gateway page and receive public content from it by `postMessage` (outside private mode, only content public by CID). @@ -966,8 +992,8 @@ constants are fixed in section 5; they matter only to writers that choose them a is not 1), flags are `true` or `false`, rates (bits/s) and times (seconds, at most 30 days) are finite numbers. Ports are 1–65535 (`gateway_port` 0: any free port); `alpha`, `beta`, `max_parallel_providers`, `max_failures`, `max_incoming_links` and `min_rate_sample` are at least 1; other sizes and counts at least 0; -rates above 0; times above 0, except `startup_announce_delay`, `provider_seniority`, `deadline_slack` and -`ban_time`, which may be 0. `members` and `remote_pin_identities` are lists of identity hashes (32 hex +rates above 0; times above 0, except `startup_announce_delay`, `provider_seniority`, `deadline_slack`, +`blocks_cooldown` and `ban_time`, which may be 0. `members` and `remote_pin_identities` are lists of identity hashes (32 hex digits). A node MUST NOT start with an invalid setting. | Name | Default | Notes | @@ -986,7 +1012,8 @@ digits). A node MUST NOT start with an invalid setting. | `provider_seniority` | 1 day | Providers in the routing table this long are tried first | | `response_target` | 60 s | Transfer time a `/blocks` response aims for | | `deadline_slack` | 30 s | Added to response deadlines; minimum age before a response can be displaced | -| `stall_timeout` | 120 s | | +| `stall_timeout` | 120 s | A response with no progress for this long is cancelled and refunded | +| `blocks_cooldown` | 60 s | A link (and its identity) whose response made no progress is refused `/blocks` this long | | `lookup_timeout` | 10 min | | | `path_timeout` | 30 s | Waiting for an RNS path | | `max_failures` | 3 | Unanswered queries in a row before a node is dropped; busy refusals in a row before a provider fails | From f00c8b95a45fed353ae8a34ece5e5c4d4d9230e7 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:49:46 +1030 Subject: [PATCH 028/107] Update the provider store tests for the newcomer pool --- tests/test_providers.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_providers.py b/tests/test_providers.py index ffc6096..32cc97a 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -155,12 +155,15 @@ class EvictionTest(StoreTestCase): max_records = 10 - def test_new_publisher_refused_when_full(self): + def test_new_publisher_evicts_a_pooled_record_when_full(self): for i in range(10): self.store.store(ident(100 + i), ident(1)) - self.assertFalse(self.store.store(KEY, ident(2))) + # ident(1)'s last record went into the newcomer pool (a tenth of the store); a new publisher evicts a + # random pooled record rather than being refused, and the records outside the pool stay + self.assertTrue(self.store.store(KEY, ident(2))) self.assertEqual(self.store.record_count(), 10) - self.assertIsNone(self.store.first_seen(ident(2))) + self.assertEqual(self.store.providers(ident(109)), []) + self.assertEqual([len(self.store.providers(ident(100 + i))) for i in range(9)], [1] * 9) def test_newest_publisher_cannot_churn_its_own_records(self): self.store.store(ident(100), ident(1)) @@ -187,15 +190,19 @@ def test_older_publisher_displaces_newest(self): self.assertTrue(self.store.store(ident(201), ident(1))) self.assertEqual(self.store.providers(ident(301)), []) - def test_flood_of_new_identities_displaces_only_itself(self): + def test_flood_of_new_identities_displaces_only_pooled_records(self): for i in range(5): self.store.store(ident(100 + i), ident(1)) self.store.store(ident(100 + i), ident(2)) self.clock.now += 1 accepted = sum(self.store.store(ident(500 + i), ident(10_000 + i)) for i in range(1000)) - self.assertEqual(accepted, 0) - for i in range(5): + # Each new identity lands in the newcomer pool (one record here), evicting the one there + self.assertEqual(accepted, 1000) + self.assertEqual(self.store.record_count(), 10) + for i in range(4): self.assertEqual(sorted(self.store.providers(ident(100 + i))), [ident(1), ident(2)]) + # ident(2)'s last record, stored once the rest of the store was full, was the one in the pool + self.assertEqual(self.store.providers(ident(104)), [ident(1)]) def test_flood_after_honest_records_only_churns_flooders(self): for i in range(6): From f17fd10005bfd05478decd7f503463380b22d64a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:49:53 +1030 Subject: [PATCH 029/107] Describe provider_seniority's new uses in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e833dc3..1fb19d0 100644 --- a/README.md +++ b/README.md @@ -541,7 +541,7 @@ lxmf_auto_fetch_from = [""] | `startup_announce_delay` | `30` | Second announce after startup | | `republish_interval` | 24 h; 3 days on slow nodes | How often provider records are republished; must be under 3.5 days | | `provide_strategy` | `"roots"` | `"roots"`: share pins and complete downloads while they're cached (up to `max_storage`); `"pinned"`: share only pins, so downloads stay private | -| `provider_seniority` | `86400` (1 day) | Providers known this long are tried first | +| `provider_seniority` | `86400` (1 day) | Providers and name keepers known this long are tried first; publishers seen this long get their full share of a full provider store | | `lookup_timeout` | `600` | Time budget of a DHT lookup | | `path_timeout` | `30` | Wait for a Reticulum path | | `max_failures` | `3` | Unanswered queries before a node is dropped; busy refusals before a provider is skipped | From 25979344da006fa265f9256e53f6ea3708581ced Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 08:57:47 +1030 Subject: [PATCH 030/107] Update the server tests for the CHUNK_SIZE budget on links with no rate estimate --- tests/security/test_l2_blocks_request_limits.py | 1 + tests/test_server.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/security/test_l2_blocks_request_limits.py b/tests/security/test_l2_blocks_request_limits.py index 0a70ca8..cb24a11 100644 --- a/tests/security/test_l2_blocks_request_limits.py +++ b/tests/security/test_l2_blocks_request_limits.py @@ -32,6 +32,7 @@ def test_server_refuses_a_duplicate_request(self): link = h.connect(FakeInterface("hub", TCP_BITRATE), mtu=1500, mdu=1400) self.assertIs(h.blocks(link, [c1, c1]), False) self.assertEqual(h.server.pools.responses(), []) + h.server.rates.record(link.link_id, 64 * KIB, 8000 * 8) # measured, so more than one block fits self.assertEqual(h.blocks(link, [c1, c2], max_bytes=MAX_RESPONSE_SIZE), [b1, b2]) # distinct served diff --git a/tests/test_server.py b/tests/test_server.py index 7b6b03e..9dff33b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -48,7 +48,7 @@ def test_leading_missing_blocks_are_none(self): self.assertEqual(server.select_blocks([c1, c2], {c2: b2}.get, 0), [None, b2]) def test_capped_by_max_response_size(self): - self.assertEqual(server.blocks_budget(10 * 1024 * KIB, None, None, 60), MAX_RESPONSE_SIZE - RESPONSE_OVERHEAD) + self.assertEqual(server.blocks_budget(10 * 1024 * KIB, 1e9, None, 60), MAX_RESPONSE_SIZE - RESPONSE_OVERHEAD) def test_capped_by_response_target_at_rate_estimate(self): self.assertEqual(server.blocks_budget(1_000_000, 8000, None, 60), 60_000) @@ -444,6 +444,7 @@ def test_small_response_is_not_tracked(self): def test_response_is_tracked_until_complete_and_gives_rate(self): link = self.connect(b"a", mtu=1500, mdu=1400) + self.server.rates.record(link.link_id, 64 * KIB, 1_000_000) # measured, so more than one block fits (c1, b1), (c2, b2) = self.big[:2] self.assertEqual(self.blocks(link, [c1, c2]), [b1, b2]) self.assertEqual(self.server.incoming.in_progress(link.link_id), (1, 1)) @@ -665,6 +666,7 @@ def test_failed_responses_refund_what_they_did_not_send(self): capacity = airtime.remaining() cids = [cid for cid, _ in self.big] link = self.connect(b"a") + self.server.rates.record(link.link_id, 64 * KIB, 1_000_000) # measured, so more than one block fits self.assertIsNot(self.blocks(link, cids), False) response = self.server.pools.responses()[0] charged = capacity - airtime.remaining() @@ -676,6 +678,7 @@ def test_failed_responses_refund_what_they_did_not_send(self): # A link closed mid-transfer refunds the rest too before = airtime.remaining() other = self.connect(b"b") + self.server.rates.record(other.link_id, 64 * KIB, 1_000_000) self.assertIsNot(self.blocks(other, cids[:2]), False) self.send(other).progress = 0.5 packed = before - airtime.remaining() @@ -726,6 +729,7 @@ def test_one_requester_holds_at_most_half_the_budget_in_flight(self): answers = [] for i in range(3): link = self.connect(bytes([0x10 + i])) + self.server.rates.record(link.link_id, 64 * KIB, 1_000_000) # measured, so more than one block fits answers.append(self.ask(BLOCKS_PATH, protocol.encode_blocks(MAX_RESPONSE_SIZE, cids), link, greedy, request_id=bytes([i]) * 16)) self.assertIsNot(answers[0], False) From a98b98558e2cc053de00556589c2f6285861e992 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:01:41 +1030 Subject: [PATCH 031/107] Cap the /blocks budget at CHUNK_SIZE on links with no rate estimate --- rnfs/network/server.py | 14 ++++++++------ specification.md | 2 +- .../security/test_l2_blocks_request_limits.py | 18 +++++------------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/rnfs/network/server.py b/rnfs/network/server.py index 96a1f30..0c3188c 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -24,8 +24,8 @@ import RNS from rnfs.config import Config -from rnfs.constants import (BLOCKS_PATH, FIND_PATH, MAX_REQUEST_SIZE, MAX_REQUESTS_PER_LINK, MAX_RESPONSE_SIZE, - PIN_PATH, PROVIDE_PATH, RESPONSE_OVERHEAD) +from rnfs.constants import (BLOCKS_PATH, CHUNK_SIZE, FIND_PATH, MAX_REQUEST_SIZE, MAX_REQUESTS_PER_LINK, + MAX_RESPONSE_SIZE, PIN_PATH, PROVIDE_PATH, RESPONSE_OVERHEAD) from rnfs.content.cid import Cid from rnfs.log import LOG_ERROR, LOG_VERBOSE, LOG_WARNING, log, node_name, plural, size from rnfs.network import guards, protocol, rates @@ -86,10 +86,12 @@ def blocks_budget(max_bytes: int, rate_estimate: float | None, upload_cap: float response_target: float) -> int: """Total block bytes a /blocks response may hold after its first served block (section 7).""" limit = min(max_bytes, MAX_RESPONSE_SIZE - RESPONSE_OVERHEAD) - if rate_estimate is not None: - rate = min(rate_estimate, upload_cap) if upload_cap else rate_estimate - limit = min(limit, rates.transfer_bytes(response_target, rate)) - return limit + if rate_estimate is None: + # What a requester asks for until the link has a rate estimate (section 7), enforced: otherwise one + # small request could ask for a ~1 MiB response with a deadline of many hours + return min(limit, CHUNK_SIZE) + rate = min(rate_estimate, upload_cap) if upload_cap else rate_estimate + return min(limit, rates.transfer_bytes(response_target, rate)) def select_blocks(cids: list[Cid], served_block: Callable[[Cid], bytes | None], limit: int) -> list[bytes | None]: diff --git a/specification.md b/specification.md index d66813a..e21a982 100644 --- a/specification.md +++ b/specification.md @@ -521,7 +521,7 @@ and repeat. - `max_bytes`; - `MAX_RESPONSE_SIZE − 1 KiB`, so the encoded response stays within `MAX_RESPONSE_SIZE`; - if the link has a rate estimate, `response_target` of transfer at the lower of that estimate and - `max_upload_rate`; + `max_upload_rate`; if it has none, `CHUNK_SIZE` (what a requester asks for then, enforced); - with an upload budget whose bucket isn't full, what the bucket holds, less 1 KiB, and in any case what is left of the requester's half share (section 3, Airtime controls). - **What a node serves:** only blocks in the DAG of a CID it currently provides (the same reachability GC diff --git a/tests/security/test_l2_blocks_request_limits.py b/tests/security/test_l2_blocks_request_limits.py index cb24a11..82361e4 100644 --- a/tests/security/test_l2_blocks_request_limits.py +++ b/tests/security/test_l2_blocks_request_limits.py @@ -1,13 +1,9 @@ """L2: the /blocks server used to serve duplicate CIDs and, on a link with no rate estimate, cap a response only at the requester's max_bytes (up to ~1 MiB). -Duplicate CIDs: FIXED here. A request that repeats a CID is refused as malformed, so a node can't be made to -read and serve the same block several times in one response. - -The unmeasured-link budget cap (at CHUNK_SIZE) is NOT applied, because doing so changes behaviour that four -tests in tests/test_server.py (another session's file) assert, and those must keep passing. The cap tests below -assert the safe (capped) behaviour and are @unittest.expectedFailure until the cap lands together with those -tests' updates (the exact diffs are in the handback report). +Both are fixed. A request that repeats a CID is refused as malformed, so a node can't be made to read and serve +the same block several times in one response; and on a link with no rate estimate the budget is CHUNK_SIZE +(the first served block is still always included), so one packet can't ask for a ~1 MiB response. """ import unittest @@ -54,16 +50,12 @@ def test_measured_link_serves_more_than_one_block(self): class UnmeasuredBudgetCapTest(unittest.TestCase): - """The unmeasured-link budget cap at CHUNK_SIZE: the safe behaviour, not yet applied (see module docstring).""" + """The unmeasured-link budget cap at CHUNK_SIZE.""" - # L2: fails until blocks_budget caps an unmeasured link at CHUNK_SIZE (and tests/test_server.py is updated) - @unittest.expectedFailure def test_budget_without_a_rate_estimate_is_capped_at_chunk_size(self): self.assertEqual(server.blocks_budget(10 * CHUNK_SIZE, None, None, 60), CHUNK_SIZE) self.assertEqual(server.blocks_budget(MAX_RESPONSE_SIZE, None, None, 60), CHUNK_SIZE) - # L2: fails until an unmeasured link's response is capped at CHUNK_SIZE - @unittest.expectedFailure def test_unmeasured_link_response_is_small(self): blocks = {cid: data for cid, data in (block_of(CHUNK_SIZE, i) for i in range(10))} h = ServerHarness(blocks) @@ -72,7 +64,7 @@ def test_unmeasured_link_response_is_small(self): self.assertEqual(len(served), 1) # one CHUNK_SIZE block, not ten def test_first_block_is_still_served_whole_regardless(self): - # The first served block is always included, up to MAX_BLOCK_SIZE, whether or not the cap applies + # The first served block is always included, up to MAX_BLOCK_SIZE, even over the cap (c1, b1), (c2, b2) = block_of(MAX_BLOCK_SIZE, 1), block_of(CHUNK_SIZE, 2) h = ServerHarness({c1: b1, c2: b2}) link = h.connect(FakeInterface("hub", TCP_BITRATE), mtu=1500, mdu=1400) From 2cd2469879e79fcd36d6be0e2ad23877c39b281e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:05:41 +1030 Subject: [PATCH 032/107] Update the server tests for charging upload_budget as responses are sent --- tests/security/test_h1_response_slots.py | 9 ++++----- tests/test_server.py | 12 ++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/security/test_h1_response_slots.py b/tests/security/test_h1_response_slots.py index 47bf399..4b4b0a1 100644 --- a/tests/security/test_h1_response_slots.py +++ b/tests/security/test_h1_response_slots.py @@ -290,8 +290,8 @@ def test_zero_progress_response_is_cancelled_and_refunded_without_newcomers(self a = self.tcp(h) resource = self.take(h, a) response = h.response(a) - charged = response.charged - self.assertGreater(charged, CHUNK_SIZE) + charged = response.charged # charged as sent: only a reservation so far + self.assertGreater(response.cost, CHUNK_SIZE) h.tick(response.grace + h.config.deadline_slack - 0.01) self.assertFalse(resource.cancelled) h.tick(0.5) @@ -307,7 +307,6 @@ def test_stalled_response_is_cancelled_and_refunded(self): resource = self.take(h, a) resource.status = RNS.Resource.TRANSFERRING response = h.response(a) - charged = response.charged for second in range(1, 11): resource.progress = 0.03 * second h.tick(1) @@ -316,8 +315,8 @@ def test_stalled_response_is_cancelled_and_refunded(self): h.tick(1) self.assertTrue(resource.cancelled) self.assertIsNone(h.response(a)) - self.assertEqual(len(refunds), 1) - self.assertAlmostEqual(refunds[0], charged - 0.3 * response.size, delta=2) # what wasn't sent + self.assertEqual(sum(refunds), 0) # charged as it was sent, so nothing to give back + self.assertAlmostEqual(response.charged, 0.3 * response.size, delta=2) # what was sent self.assertFalse(h.server.cooldowns.active(a.link_id)) # it was moving well above its floor def test_complete_resource_is_concluded_not_cancelled(self): diff --git a/tests/test_server.py b/tests/test_server.py index 9dff33b..7a50335 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -669,8 +669,8 @@ def test_failed_responses_refund_what_they_did_not_send(self): self.server.rates.record(link.link_id, 64 * KIB, 1_000_000) # measured, so more than one block fits self.assertIsNot(self.blocks(link, cids), False) response = self.server.pools.responses()[0] - charged = capacity - airtime.remaining() - self.assertGreater(charged, 300 * KIB) + self.assertGreater(response.cost, 300 * KIB) + self.assertLessEqual(capacity - airtime.remaining(), link.mdu) # charged as sent: a reservation so far resource = self.send(link) resource.progress = 0.25 resource.cancel() # RNS: rejected or timed out, then the callback @@ -681,7 +681,7 @@ def test_failed_responses_refund_what_they_did_not_send(self): self.server.rates.record(other.link_id, 64 * KIB, 1_000_000) self.assertIsNot(self.blocks(other, cids[:2]), False) self.send(other).progress = 0.5 - packed = before - airtime.remaining() + packed = self.server.pools.responses()[0].size other.teardown() self.assertAlmostEqual(before - airtime.remaining(), packed / 2, delta=2) # A response cancelled before its Resource started (missed deadline) is refunded whole @@ -696,7 +696,7 @@ def test_unreadable_progress_keeps_the_charge(self): capacity = airtime.remaining() link = self.connect(b"a") self.assertIsNot(self.blocks(link), False) - charged = capacity - airtime.remaining() + charged = self.server.pools.responses()[0].size # how much was sent is unknown: all of it resource = self.send(link) def unreadable(): @@ -712,7 +712,7 @@ def test_completed_responses_keep_their_charge(self): capacity = airtime.remaining() link = self.connect(b"a") self.assertIsNot(self.blocks(link), False) - charged = capacity - airtime.remaining() + charged = self.server.pools.responses()[0].size resource = self.send(link) resource.progress = 1.0 resource.complete() @@ -737,7 +737,7 @@ def test_one_requester_holds_at_most_half_the_budget_in_flight(self): self.assertEqual(len(served), 3) # cut to half the bucket self.assertIs(answers[2], False) self.assertEqual(self.server.traffic.snapshot()["refused"]["budget"], 1) - in_flight = sum(response.charged for response in self.server.pools.responses()) + in_flight = sum(response.cost for response in self.server.pools.responses()) self.assertLessEqual(in_flight, 400 * KIB + 2 * 64 * KIB) self.assertIsNot(self.ask(BLOCKS_PATH, protocol.encode_blocks(MAX_RESPONSE_SIZE, cids[:1]), self.connect(b"z"), RNS.Identity()), False) From 07264173380cc82066620b5bf76b3de447e51c0e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:05:41 +1030 Subject: [PATCH 033/107] Charge upload_budget for bytes actually sent, not a response's full size up front --- design.md | 15 +- rnfs/mesh.py | 37 ++++- rnfs/network/server.py | 124 ++++++++++----- specification.md | 37 +++-- tests/security/test_m2_upload_budget.py | 194 ++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 59 deletions(-) create mode 100644 tests/security/test_m2_upload_budget.py diff --git a/design.md b/design.md index 2dc6174..089bdfc 100644 --- a/design.md +++ b/design.md @@ -306,12 +306,15 @@ and in total, never persisted. `/blocks` from a link (and its identity, if identified) whose response was displaced or cancelled for making no progress, for `blocks_cooldown`, cheaply and before any slot is taken. - `RequestServer.airtime` checks interface rules, quiet hours and an empty budget before taking any slot, - caps the block budget, and charges the bucket once the response's size is known. Each `Response` records - its charge and its requester (identity hash, else link ID); one requester's unsettled charges may reach at - most half the bucket's capacity (`_budget_share_left` cuts the budget, or refuses). When a response - concludes (`_finish`), a failed, cancelled, displaced or never-started Resource, or one whose link closed, - refunds `charged − progress × size`; completed, single-packet and untracked responses, and ones whose - progress can't be read, keep their charge. + caps the block budget, and charges the bucket as responses are sent. Each `Response` records its `cost` + (packed size), what has been `charged` so far and its requester (identity hash, else link ID). `_admit` + (one admission at a time) allows a response if the bucket holds its cost beyond `_committed()` (the + uncharged rest of other responses within their grace or that have sent something), and charges a + reservation of at most the link's MDU; single-packet and untracked responses are charged in full. + `check_responses` charges progress as it reads it (`_charge_progress`), and `_settle` (from `_finish`) + makes the charge final: the cost if completed or progress is unreadable, else progress × size, refunding + an unused reservation. One requester's responses in flight may cost at most half the bucket's capacity + (`_budget_share_left` cuts the budget, or refuses). `RequestServer.traffic` and `PeerClient.traffic` share one `Traffic`. - `network/guards.py` also rejects, on `rnfs.dht` links only (recognised by the link's destination or owner name `rnfs.dht.`), any Resource advertisement with more than one segment. diff --git a/rnfs/mesh.py b/rnfs/mesh.py index 9ef895c..55b31e4 100644 --- a/rnfs/mesh.py +++ b/rnfs/mesh.py @@ -252,6 +252,22 @@ def give_back(self, nbytes: int): self._refill() self.tokens = min(self.capacity, self.tokens + nbytes) + def reserve(self, nbytes: int, reservation: int, committed: int = 0) -> bool: + """Spend reservation of a response of nbytes if the response is allowed (see the class), counting + committed bytes (owed by responses in flight but not yet spent) as gone. False, spending nothing, if not.""" + with self.lock: + self._refill() + if self.tokens - committed >= nbytes or (self.tokens >= self.capacity and committed <= 0): + self.tokens -= reservation + return True + return False + + def charge(self, nbytes: int): + """Spend nbytes that have been sent, into debt if need be.""" + with self.lock: + self._refill() + self.tokens -= nbytes + class AirtimePolicy: """The airtime controls on /blocks: upload_budget, serve_interfaces / no_serve_interfaces, quiet_hours. @@ -279,20 +295,31 @@ def refusal(self, interface_names_: list[str]) -> str | None: return REFUSED_BUDGET return None - def limit(self, limit: int) -> int: - """A response's block budget (after its first block) cut to what the upload budget holds, unless - the bucket is full (when any one response may go).""" + def limit(self, limit: int, committed: int = 0) -> int: + """A response's block budget (after its first block) cut to what the upload budget holds less what + responses in flight are still committed to send, unless the bucket is full and nothing is committed + (when any one response may go).""" if self.bucket is None: return limit available = self.bucket.available() - if available >= self.bucket.capacity: + if available >= self.bucket.capacity and committed <= 0: return limit - return max(0, min(limit, int(available) - RESPONSE_OVERHEAD)) + return max(0, min(limit, int(available - committed) - RESPONSE_OVERHEAD)) def spend(self, nbytes: int) -> bool: """Charge a response of nbytes to the upload budget. False if it must be refused.""" return self.bucket is None or self.bucket.take(nbytes) + def reserve(self, nbytes: int, reservation: int, committed: int = 0) -> bool: + """Admit a response of nbytes, charging reservation of it now (the rest is charged as it is sent). + False if it must be refused; committed is what responses in flight are still committed to send.""" + return self.bucket is None or self.bucket.reserve(nbytes, reservation, committed) + + def charge(self, nbytes: int): + """Charge nbytes of an admitted response as they are sent.""" + if self.bucket is not None and nbytes > 0: + self.bucket.charge(nbytes) + def refund(self, nbytes: int): """A charged response wasn't sent.""" if self.bucket is not None and nbytes: diff --git a/rnfs/network/server.py b/rnfs/network/server.py index 0c3188c..00f3ab9 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -175,7 +175,10 @@ def __init__(self, link_id: bytes, request_id: bytes, pool: str, started: float) self.concluded = False # its Resource's conclusion has been handled self.finished = False self.done = threading.Event() # set once finished, to cut short a pacing wait - self.charged = 0 # bytes of upload_budget charged and not yet settled (refunded, or kept once sent) + # upload_budget (section 3): charged as it is sent, not up front + self.cost = 0 # packed bytes it will cost the budget if all of it is sent (0: not charged) + self.charged = 0 # bytes charged so far: a small reservation, then what has been sent + self.settled = False # its charge is final: it concluded, or how much it sends can't be followed self.requester: bytes | None = None # identity hash (else link ID) whose budget share it counts against self.identity: bytes | None = None # the requester's identity hash, if it identified # Displacement protection (section 3) @@ -756,7 +759,8 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): # The upload cap applies once the link has a rate estimate (section 3) cap = self.config.effective_max_upload_rate(slow, rate) if rate is not None else None - limit = self.airtime.limit(blocks_budget(max_bytes, rate, cap, self.config.response_target)) + limit = self.airtime.limit(blocks_budget(max_bytes, rate, cap, self.config.response_target), + self._committed(response)) response.requester = identity if identity is not None else link_id share = self._budget_share_left(response) if share is not None: @@ -768,17 +772,19 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): entries = select_blocks(cids, self.backend.served_block, limit) body = protocol.encode_blocks_response(entries) packed = protocol.packed_response_size(request_id, body) - # The upload budget is charged for responses carrying blocks; answers of only None are tiny - charged = packed if any(entry is not None for entry in entries) else 0 - if charged and not self.airtime.spend(charged): + mdu = link.get_mdu() or protocol.DEFAULT_MDU + single = packed <= mdu + untracked = link_id not in self.watched + # The upload budget is charged for responses carrying blocks (answers of only None are tiny), as + # they are sent: up front only a reservation of about one packet (its advertisement), or all of it + # if it goes as one packet or its progress can't be followed + if any(entry is not None for entry in entries) and not self._admit( + response, packed, packed if single or untracked else min(packed, mdu)): self.traffic.refused(mesh.REFUSED_BUDGET) - log(f"refused /blocks: {size(charged)} is over what is left of upload_budget", LOG_VERBOSE) + log(f"refused /blocks: {size(packed)} is over what is left of upload_budget", LOG_VERBOSE) return False - with self.lock: - response.charged = charged # settled when it concludes: what wasn't sent is refunded if response.finished: - self._refund(response) # unless finishing it (displacement) refunded it already - return False # displaced while it was being built + return False # displaced while it was being built (_admit charged nothing, or _finish settled) with self.lock: if link_id in self.links: # a closed link's entry would never be forgotten self.pacer.started(link_id, packed, cap) @@ -786,17 +792,19 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): log(f"/blocks: sending {plural(served, 'block')} of {len(cids)} requested ({size(packed)})", LOG_VERBOSE) self.traffic.sent(node_id_for_identity(remote_identity) if remote_identity is not None else None, mesh.interface_label(getattr(link, "attached_interface", None)), packed) - mdu = link.get_mdu() or protocol.DEFAULT_MDU - if packed <= mdu: + if single: with self.lock: - response.charged = 0 # sent as a single packet once we return: the charge stands + response.settled = True # sent as a single packet once we return: the charge stands return body # nothing to track response.size = packed response.deadline = response.started + rates.response_deadline(packed, rate, self.config) response.grace = grace self._restart_grace(response) response.find_until = self.clock() + REGISTER_WINDOW - response.untracked = link_id not in self.watched + response.untracked = untracked + if untracked: + with self.lock: + response.settled = True # charged in full: how much is sent can't be followed # From here the slots are freed when the Resource concludes, is cancelled, or the link closes handed_over = True self.wake.set() @@ -843,8 +851,6 @@ def _previous_done(self, link, link_id: bytes) -> bool: if response.untracked: # Without RNS internals, the requester asking again is the only sign the response arrived self.rates.record(link_id, response.size, self._expected_rate(link)) - with self.lock: - response.charged = 0 self._finish(response) continue resource = response.resource @@ -914,42 +920,90 @@ def _finish(self, response: Response): return response.finished = True response.done.set() - self._refund(response) + self._settle(response) self.pools.release(response) self.incoming.finish_request(response.link_id, blocks=True) - def _refund(self, response: Response): - """Give upload_budget back what a response that didn't complete was charged but didn't send. + def _admit(self, response: Response, cost: int, reservation: int) -> bool: + """Charge upload_budget reservation of a response that will cost it cost bytes, if the budget allows + the response. False if it must be refused. - Without this, a requester that rejects (or never fetches) its responses' Resources would drain the - node-wide budget while receiving nothing. A completed one keeps its charge, and so does one we can't - follow (untracked) or whose progress can't be read: how much was sent is unknown. + Admissions are taken one at a time, each counting what the others are still committed to send, so + responses admitted together can't overrun what the bucket holds. """ with self.lock: - charged, response.charged = response.charged, 0 + if response.finished: + return True # displaced meanwhile: nothing to charge, and the caller sees it finished + if not self.airtime.reserve(cost, reservation, self._committed(response)): + return False + response.cost, response.charged = cost, reservation + return True + + def _committed(self, response: Response | None = None) -> int: + """Budget bytes the responses in flight (other than response) are committed to send but haven't been + charged: each one's unsent rest while it is within its grace or has sent something. A response past its + grace with nothing sent (one its requester isn't fetching) commits nothing.""" + if self.airtime.bucket is None: + return 0 + now = self.clock() + return sum(max(0, other.cost - other.charged) for other in self.pools.responses() + if other is not response and not other.finished and not other.settled and other.cost + and (other.in_grace(now) or other.sent > 0)) + + def _charge_progress(self, response: Response): + """Charge upload_budget for what response has sent since it was last charged.""" + with self.lock: + if response.settled or not response.cost: + return + sent = min(response.cost, int(response.sent)) + extra = sent - response.charged + if extra <= 0: + return + response.charged = sent + self.airtime.charge(extra) + + def _settle(self, response: Response): + """Make a concluded response's budget charge final, once: what it sent (its whole cost if it completed, + or if how much it sent can't be read), with any reservation it didn't use given back. + + So a requester that rejects (or never fetches) its responses' Resources costs the node-wide budget + nothing beyond them being in flight, and a cancelled one costs what was sent. + """ + with self.lock: + if response.settled or not response.cost: + response.settled = True + return + response.settled = True + charged = response.charged resource = response.resource - if not charged or response.untracked or getattr(resource, "status", None) == RNS.Resource.COMPLETE: - return - sent = response.sent - if resource is not None: + final = response.cost + if getattr(resource, "status", None) != RNS.Resource.COMPLETE: try: - sent = resource.get_progress() * (response.size or 0) + sent = resource.get_progress() * (response.size or 0) if resource is not None else response.sent + final = min(response.cost, int(max(0.0, sent))) except Exception: - return - self.airtime.refund(int(charged - min(charged, max(0.0, sent)))) + pass # progress unknown: it is charged in full + with self.lock: + response.charged = final + if final > charged: + self.airtime.charge(final - charged) + elif final < charged: + self.airtime.refund(charged - final) def _budget_share_left(self, response: Response) -> int | None: """Budget bytes response's requester may still have in flight (None without upload_budget). - One requester's responses in flight may hold at most half the bucket's capacity, so withholding - proofs on several links can't hold the whole budget until their deadlines pass. + One requester's responses in flight may cost at most half the bucket's capacity in all, so one requester + can't size its responses to take the whole budget at once and leave others none. Unidentified links + each count as their own requester and fresh identities are free, so this is fairness among requesters, + not a bound on a determined one: what bounds that is that the budget is only charged as bytes are sent. """ bucket = self.airtime.bucket if bucket is None: return None responses = self.pools.responses() with self.lock: - in_flight = sum(other.charged for other in responses + in_flight = sum(other.cost for other in responses if other is not response and other.requester == response.requester and not other.finished) return int(bucket.capacity // 2) - in_flight @@ -960,7 +1014,7 @@ def _concluded(self, response: Response, link, resource): first, response.concluded = not response.concluded, True if first and getattr(resource, "status", None) == RNS.Resource.COMPLETE: self.rates.record(response.link_id, response.size, self._expected_rate(link)) - self._finish(response) # refunds what a failed or cancelled Resource didn't send + self._finish(response) # charges what was sent: all of a completed Resource def _cancel(self, response: Response, reason: str): """Stop sending a response: cancel its Resource, or close its link if we can't reach the Resource.""" @@ -1049,6 +1103,8 @@ def check_responses(self) -> bool: response.note_progress(resource.get_progress() * response.size, now) except Exception: resource = None # progress unknown: only the deadline applies + else: + self._charge_progress(response) if resource is not None and self._check_progress(response, now): continue diff --git a/specification.md b/specification.md index e21a982..d54cc62 100644 --- a/specification.md +++ b/specification.md @@ -199,20 +199,29 @@ refused by them SHOULD be answered at once, before a response slot is reserved o inclusive and end exclusive; a range may wrap past midnight. During a range `/blocks` MUST be refused. - **Upload budget** (`upload_budget`, bytes per hour, node-wide): a token bucket of `upload_budget` bytes, refilled continuously at `upload_budget / 3600` bytes per second, starting full. - - A response carrying at least one block is charged its packed size. It is allowed if the bucket holds at - least that size, or if the bucket is full, so one honest response larger than what is left is delayed but - never starved; the bucket may go into debt this way. A response refused for its size is not charged. - - **Per requester.** One requester (its identity; each unidentified link counts as its own) may have at most - half the bucket's capacity charged in responses still in flight. Its response's block budget is cut to + - A response carrying at least one block costs its packed size, **charged as it is sent** (from its + Resource's progress), not up front. It is allowed if the bucket holds at least that size beyond what the + responses in flight are still committed to send, or if the bucket is full and nothing is committed, so one + honest response larger than what is left is delayed but never starved; the bucket may go into debt this + way. A response refused for its size is not charged. + - **Reservation and commitment.** When it is allowed, a response is charged a reservation of at most one + packet (the link's MDU: its advertisement). The rest of it counts as committed, when admitting other + responses, while it is within its grace (Displacement, above) or once it has sent something, so responses + admitted together can't overrun the bucket. A response past its grace with nothing sent commits nothing: + responses a requester never downloads hold at most their reservations. A single-packet response, and one + whose progress can't be followed, is charged in full when it is allowed. + - **Per requester.** One requester (its identity; each unidentified link counts as its own) may have + responses in flight costing at most half the bucket's capacity. Its response's block budget is cut to what is left of that share (the first block is always included, section 7), and a requester at its share is refused. So with a full bucket one response is at most about `upload_budget / 2`, and one requester - can't take the whole budget. - - **Settlement.** When a charged response ends without completing (it failed, was cancelled, missed its - deadline, was displaced, never started, or its link closed), what it didn't send is given back: the - charge less its progress times its size. Completed responses, single-packet responses and responses whose - progress can't be read keep their charge. - - While the bucket is not full, the response's block budget (section 7) is cut to what the bucket holds, - less 1 KiB. + can't size its responses to take the whole budget at once. Identities are free, so this shares the bucket + among requesters rather than bounding a determined one; what keeps a requester that withholds downloads + from holding the budget is that only bytes sent are charged. + - **Settlement.** When a response concludes (it completed, failed, was cancelled, missed its deadline, was + displaced, never started, or its link closed), its charge is made final: its size if it completed or its + progress can't be read, else its progress times its size. A reservation it didn't use is given back. + - While the bucket is not full, or responses in flight are committed, the response's block budget (section + 7) is cut to what the bucket holds less what is committed, less 1 KiB. - A request arriving while the bucket is empty or in debt is refused before its response is built. - Responses of only `None` entries are not charged. @@ -772,7 +781,7 @@ as is cheap. Rules live in the sections referenced. | Forged or withheld kept names | Records verified against the name's key; highest persisted sequence; several keepers asked (9) | | Tampered or spliced sealed files | Per-segment HMAC bound to the header, index and final flag, checked before decrypting (5) | | Unsolicited LXMF links, inbox flooding | Strict field parsing, size caps per link, per message and for the whole inbox; at most 50 kept messages per untrusted sender; auto-fetched pins never pushed out by others' messages; signature-validated senders only; rate-limited path requests for unknown senders; `lxmf_accept_from`, per-sender `lxmf_auto_fetch_quota` (13) | -| Uploads exhausting a shared channel | `upload_budget` (at most half of it per requester in flight, unsent parts refunded), interface rules and quiet hours, applied to `/blocks` only (3) | +| Uploads exhausting a shared channel | `upload_budget` (charged for bytes sent, at most half of it per requester in flight), interface rules and quiet hours, applied to `/blocks` only (3) | | Names and paths chosen by others reaching the local user | Default output names only if printable, not hidden, not starting with `-` and one valid file name, else the CID; non-printable characters escaped in output and logs (11) | | Republishing and retries draining airtime | Small `keep_max_names`; slower, jittered republishing of name keys; paced lookups on slow nodes; follow backoff (6, 9) | @@ -1068,7 +1077,7 @@ digits). A node MUST NOT start with an invalid setting. | `default_bootstrap` | on | Also contact the default bootstrap nodes when joining; never in private mode (section 6) | | `reticulum_dir` | RNS's own | RNS config directory, the one `rnsd` uses by default | | `verbosity` | 0 | Extra logging: 1 per-query detail, 2 RNS debug output | -| `upload_budget` | none (unlimited) | Bytes per hour of `/blocks` responses, node-wide; a token bucket, at most half of it per requester in flight, unsent parts refunded (section 3) | +| `upload_budget` | none (unlimited) | Bytes per hour of `/blocks` responses, node-wide; a token bucket charged for bytes sent, at most half of it per requester in flight (section 3) | | `serve_interfaces` | empty (all) | Serve `/blocks` only on these RNS interfaces (names or prefixes, case-insensitive; a spawned interface also matches its parent's name) | | `no_serve_interfaces` | empty | Never serve `/blocks` on these interfaces; takes precedence over `serve_interfaces` | | `quiet_hours` | none | Local time ranges such as `22:00-06:00` or `01:00-05:00,12:00-13:00`, during which `/blocks` is refused | diff --git a/tests/security/test_m2_upload_budget.py b/tests/security/test_m2_upload_budget.py new file mode 100644 index 0000000..315d0e7 --- /dev/null +++ b/tests/security/test_m2_upload_budget.py @@ -0,0 +1,194 @@ +"""M2: upload_budget was charged a response's full size up front and refunded only when it ended, so responses a +requester never downloaded (on links that each count as their own requester) held the bucket's capacity. + +Fixed: the bucket is charged as bytes are sent, from the Resource's progress. Up front a response is charged +only a reservation of about one packet (its advertisement); while it is within its grace (or once it has sent +something) the rest of it is counted as committed when admitting other responses, so responses admitted +together can't overrun the bucket; past its grace with nothing sent it commits nothing. When it concludes its +charge is made final: its size if it completed, what it sent if not. Driven with the fakes and a FakeClock. +""" +import unittest + +import RNS + +from rnfs import mesh +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE, KIB, MAX_RESPONSE_SIZE +from tests.security._server_helpers import TCP_BITRATE, FakeInterface, ServerHarness +from tests.support import FakeClock +from tests.test_server import block_of + +MDU = 1400 # the fake TCP links' MDU: the most a reservation is +BUDGET = 500_000 # the audit's example: a LoRa-like hourly budget +REFILL = 0.25 * BUDGET / 3600 + 2 # what the bucket refills over one 0.25 s monitor tick, and rounding + + +class TokenBucketReserveTest(unittest.TestCase): + """TokenBucket.reserve and charge: admission counts committed bytes; only the reservation is taken.""" + + def test_reserve_takes_only_the_reservation(self): + bucket = mesh.TokenBucket(10_000, clock=FakeClock()) + self.assertTrue(bucket.reserve(8000, 500)) + self.assertEqual(bucket.available(), 9500) + self.assertFalse(bucket.reserve(2000, 500, committed=7600)) # 9500 - 7600 < 2000 + self.assertEqual(bucket.available(), 9500) + self.assertTrue(bucket.reserve(2000, 500, committed=7000)) + bucket.charge(12_000) # bytes sent are charged, into debt if need be + self.assertEqual(bucket.available(), -3000) + + def test_full_bucket_allows_one_oversized_response_only_when_nothing_is_committed(self): + bucket = mesh.TokenBucket(10_000, clock=FakeClock()) + self.assertFalse(bucket.reserve(20_000, 500, committed=1)) + self.assertTrue(bucket.reserve(20_000, 500)) + + +class UploadBudgetTest(unittest.TestCase): + """upload_budget is charged for bytes actually sent.""" + + def setUp(self): + self.big = [block_of(CHUNK_SIZE, i) for i in range(6)] + self.cids = [cid for cid, _ in self.big] + self.hub = FakeInterface("TCP hub", TCP_BITRATE) + self.h = ServerHarness(dict(self.big), Config(upload_budget=BUDGET, max_blocks_responses=8)) + self.airtime = self.h.server.airtime + + def link(self): + """A fast link over the TCP hub that already has a rate estimate, so responses of several blocks fit.""" + link = self.h.connect(self.hub, mtu=1500, mdu=MDU) + self.h.server.rates.record(link.link_id, 64 * KIB, TCP_BITRATE) + return link + + def request(self, link, count=3): + """/blocks for count blocks on link, accepted, and its Resource advertised: (response, resource).""" + answer = self.h.blocks(link, self.cids[:count], max_bytes=MAX_RESPONSE_SIZE) + self.assertIsNot(answer, False) + return self.h.response(link), self.h.advertise(link) + + def spent(self) -> float: + """Bytes taken from the bucket so far, less what it has refilled since (about REFILL a monitor tick).""" + return BUDGET - self.airtime.bucket.available() + + def test_undownloaded_response_holds_at_most_the_reservation(self): + response, resource = self.request(self.link()) + self.assertGreater(response.cost, 3 * CHUNK_SIZE) + self.assertLessEqual(self.spent(), MDU) + self.h.tick(response.grace + 1) # its grace is over and it has sent nothing + self.assertLessEqual(self.spent(), MDU) + self.assertEqual(self.h.server._committed(), 0) + self.assertFalse(resource.cancelled) # still in flight, still holding no more than the reservation + + def test_the_m2_attack_no_longer_shrinks_the_bucket(self): + """Two unidentified links each ask for a large response and never download it; an honest requester + arriving once their grace is over is served in full (before the fix: cut to ~107 kB of 500 kB).""" + held = [self.request(self.link()) for _ in range(2)] + self.h.tick(max(response.grace for response, _ in held) + 1) + self.assertLessEqual(self.spent(), 2 * MDU) + honest = self.link() + answer = self.h.blocks(honest, self.cids[3:6], max_bytes=MAX_RESPONSE_SIZE) + self.assertIsNot(answer, False) + self.assertEqual(len([entry for entry in answer if entry is not None]), 3) # not cut by the held ones + self.assertEqual(self.h.server.traffic.snapshot()["refused"].get(mesh.REFUSED_BUDGET, 0), 0) + + def test_completed_response_is_charged_its_size(self): + response, resource = self.request(self.link()) + resource.progress = 1.0 + resource.complete() + self.assertTrue(response.finished) + self.assertAlmostEqual(self.spent(), response.cost, delta=1) + + def test_completed_between_monitor_passes_is_charged_its_size(self): + response, resource = self.request(self.link()) + resource.status = RNS.Resource.COMPLETE # no progress seen, then complete + self.h.tick(0.25) + self.assertTrue(response.finished) + self.assertAlmostEqual(self.spent(), response.cost, delta=REFILL) + + def test_charged_as_progress_arrives(self): + response, resource = self.request(self.link()) + resource.status = RNS.Resource.TRANSFERRING + for fraction in (0.1, 0.5, 0.8): + resource.progress = fraction + self.h.tick(0.25) + self.assertAlmostEqual(self.spent(), fraction * response.cost, delta=REFILL * 3) + + def test_partly_sent_then_cancelled_is_charged_what_was_sent(self): + response, resource = self.request(self.link()) + resource.status = RNS.Resource.TRANSFERRING + resource.progress = 0.2 + self.h.tick(0.25) + resource.progress = 0.4 + resource.cancel() # RNS: the requester rejected it, or it timed out + self.assertTrue(response.finished) + self.assertAlmostEqual(self.spent(), 0.4 * response.cost, delta=REFILL) + + def test_link_closed_mid_transfer_is_charged_what_was_sent(self): + link = self.link() + response, resource = self.request(link) + resource.status = RNS.Resource.TRANSFERRING + resource.progress = 0.25 + link.teardown() + self.assertAlmostEqual(self.spent(), 0.25 * response.cost, delta=2) + + def test_undownloaded_and_cancelled_costs_nothing(self): + link = self.link() + response, resource = self.request(link) + self.h.tick(response.grace + self.h.config.deadline_slack + 1) # cancelled for making no progress + self.assertTrue(resource.cancelled) + self.assertAlmostEqual(self.spent(), 0, delta=1) + + def test_responses_admitted_together_cannot_overrun_the_bucket(self): + """Responses within their grace commit their size, so one admitted meanwhile is cut to what is left.""" + held = [self.request(self.link(), count=3)[0] for _ in range(2)] + third = self.link() + answer = self.h.blocks(third, self.cids[3:6], max_bytes=MAX_RESPONSE_SIZE) + self.assertIsNot(answer, False) + self.assertEqual(len([entry for entry in answer if entry is not None]), 1) # ~107 kB left: one block + costs = sum(response.cost for response in held) + self.h.response(third).cost + self.assertLessEqual(costs, BUDGET) + + def test_bucket_refills_as_before(self): + response, resource = self.request(self.link()) + resource.progress = 1.0 + resource.complete() + self.assertGreater(self.spent(), 3 * CHUNK_SIZE) + self.h.clock.now += 1800 # half an hour: half the budget back + self.assertAlmostEqual(self.spent(), max(0, response.cost - BUDGET / 2), delta=2) + self.h.clock.now += 1800 + self.assertEqual(self.airtime.remaining(), BUDGET) + + def test_half_share_still_bounds_one_requester(self): + """One identity's responses in flight cost at most half the bucket: its own undownloaded responses + use up its share, while another requester is still served in full.""" + greedy = RNS.Identity() + answers = [] + for _ in range(3): + link = self.link() + answers.append(self.h.blocks(link, self.cids[:3], max_bytes=MAX_RESPONSE_SIZE, identity=greedy)) + if answers[-1] is not False: + self.h.advertise(link) + self.assertIsNot(answers[0], False) + in_flight = sum(r.cost for r in self.h.server.pools.responses() if r.identity == bytes(greedy.hash)) + self.assertLessEqual(in_flight, BUDGET // 2 + CHUNK_SIZE) + self.assertIs(answers[-1], False) + self.h.tick(max(r.grace for r in self.h.server.pools.responses()) + 1) + other = self.link() + answer = self.h.blocks(other, self.cids[3:6], max_bytes=MAX_RESPONSE_SIZE, identity=RNS.Identity()) + self.assertEqual(len([entry for entry in answer if entry is not None]), 3) + + def test_single_packet_and_untracked_responses_are_charged_in_full(self): + small = block_of(300, 99) + h = ServerHarness({small[0]: small[1], **dict(self.big)}, Config(upload_budget=BUDGET)) + link = h.connect(self.hub, mtu=1500, mdu=MDU) + self.assertIsNot(h.blocks(link, [small[0]]), False) # fits one packet: sent as we return + packet = BUDGET - h.server.airtime.bucket.available() + self.assertGreater(packet, 300) + blind = h.connect(self.hub, mtu=1500, mdu=MDU) + h.server.watched.discard(blind.link_id) # RNS internals unavailable: progress can't be followed + self.assertIsNot(h.blocks(blind, [self.cids[0]]), False) + response = h.response(blind) + self.assertTrue(response.untracked) + self.assertAlmostEqual(BUDGET - h.server.airtime.bucket.available(), packet + response.cost, delta=1) + + +if __name__ == "__main__": + unittest.main() From 09c6324be4b8fe573399e2327e57f8621810f73a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:13:50 +1030 Subject: [PATCH 034/107] Keep half-open incoming links in a capped bucket of their own, closing the oldest when it is full --- design.md | 8 +- rnfs/features/lxmf.py | 3 + rnfs/network/guards.py | 106 ++++++++++- rnfs/network/server.py | 2 + specification.md | 11 +- tests/security/_m1_real_helpers.py | 36 ++++ tests/security/test_m1_half_open_links.py | 207 +++++++++++++++++++++ tests/security/test_m1_real_rns.py | 108 +++++++++++ tests/security/test_rns_compat_security.py | 70 ++++++- 9 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 tests/security/_m1_real_helpers.py create mode 100644 tests/security/test_m1_half_open_links.py create mode 100644 tests/security/test_m1_real_rns.py diff --git a/design.md b/design.md index 089bdfc..4370ec0 100644 --- a/design.md +++ b/design.md @@ -66,7 +66,8 @@ rnfs/ protocol.py message encoding/validation, batch sizes (sections 3, 6, 7, 8) rates.py rate estimates, slow links, slow node detection (section 2) links.py LinkPool: shared unidentified links, identified one-shot links, idle close (section 3) - guards.py process-wide guard rejecting oversized or request Resource advertisements (3, 10) + guards.py process-wide guard rejecting oversized or request Resource advertisements (3, 10); the + half-open incoming link cap per destination (3) server.py RequestServer: handlers, every section 3 limit, airtime policy, feature paths (3, 7) client.py PeerClient: /find, /provide, /blocks (with stall detection), /pin, ping (sections 3, 6, 7) announces.py rnfs.dht announces carrying the DHT mode, and the announce handler (section 3) @@ -318,6 +319,11 @@ and in total, never persisted. `RequestServer.traffic` and `PeerClient.traffic` share one `Traffic`. - `network/guards.py` also rejects, on `rnfs.dht` links only (recognised by the link's destination or owner name `rnfs.dht.`), any Resource advertisement with more than one segment. +- `guards.limit_half_open(destination, limit)` replaces a destination's `incoming_link_request` (on the + instance; RNS's `Destination.receive` calls it for each link request) with `HalfOpenLimit`, which, before + RNS validates a request, closes (`Link.teardown()`) the oldest `PENDING`/`HANDSHAKE` link in + `destination.links` on the interface holding the most until fewer than `limit` remain. `RequestServer` + applies it to `rnfs.dht` and the LXMF feature to its delivery destination, both at `max_incoming_links`. ## Features diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index 43f3d85..7773a62 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -1543,6 +1543,7 @@ def __init__(self, feature: LxmfFeature, config, node_id: bytes): import RNS from RNS.vendor import umsgpack + from rnfs.network import guards from rnfs.network.identity import load_or_create_identity self.RNS, self.LXMF, self.msgpack = RNS, LXMF, umsgpack self.feature = feature @@ -1587,6 +1588,8 @@ def request_messages(identity, max_messages=SYNC_MAX_MESSAGES): self.router.request_messages_from_propagation_node = request_messages self.destination = self.router.register_delivery_identity(self.identity, display_name=self.display_name) + # The delivery destination takes link requests too: bound its half-open links as rnfs.dht's (section 3) + guards.limit_half_open(self.destination, config.max_incoming_links) self.address = bytes(self.destination.hash) self.router.register_delivery_callback(self._delivered) self._announce_handler = None diff --git a/rnfs/network/guards.py b/rnfs/network/guards.py index fd5bdc4..ee2609f 100644 --- a/rnfs/network/guards.py +++ b/rnfs/network/guards.py @@ -1,4 +1,5 @@ -"""A process-wide guard on incoming Resource advertisements (sections 3 and 10: oversized-response bombs). +"""Guards on RNS: a process-wide one on incoming Resource advertisements (sections 3 and 10: oversized-response +bombs), and one per destination on half-open incoming links (section 3: link exhaustion). RNS checks Destination.set_max_request_size and a request's max_response_size only against an advertisement's data size (d), but receives, buffers and allocates by its transfer size (t). RNS 1.5.6 caps t @@ -12,14 +13,26 @@ response of many segments (hundreds of MiB, later read wholly into memory) would pass them, while every legitimate rnfs response fits one. Other destinations' Resources (LXMF's, whose messages may span segments) are told apart by the link's destination name, which this node chose itself. + +It also bounds half-open incoming links (section 3, link exhaustion). For every link request, RNS's +Destination.incoming_link_request runs Link.validate_request, which does the key exchange, sends a proof, +registers the link and starts a watchdog thread; the link then waits up to 6 s × hops + 360 s for the RTT +packet that makes it ACTIVE, and only then tells rnfs (the link established callback, where +max_incoming_links applies). limit_half_open wraps a destination's incoming_link_request so that, before a new +request is validated, links still waiting (PENDING or HANDSHAKE) are kept below a cap of their own: when it is +reached, the oldest half-open link on the interface holding the most is closed, rather than the new one +refused. An honest handshake completes within about one round trip, so a flood can't lock honest initiators +out by keeping the cap full, and a flood on one interface closes its own links first. """ import threading +import time +from collections import Counter import RNS from rnfs import __version__ from rnfs.constants import APP_NAME, DHT_ASPECT, MAX_RESPONSE_SIZE -from rnfs.log import LOG_ERROR, LOG_WARNING, log +from rnfs.log import LOG_ERROR, LOG_WARNING, log, plural # Resource data is a 4-byte random hash plus the payload, encrypted as an RNS Token: 16-byte IV, 32-byte HMAC # and up to 16 bytes of PKCS7 padding. 128 bytes leaves room without letting the transfer outgrow the data. @@ -122,3 +135,92 @@ def installed() -> bool: """True if the guard is in place.""" current = RNS.Resource.__dict__.get("accept") return getattr(current, "__func__", current) is guarded_accept + + +# Half-open incoming links + +HALF_OPEN_LOG_INTERVAL = 60.0 # seconds between log lines about closing half-open links + + +def half_open_links(destination) -> list: + """destination's incoming links not yet ACTIVE (PENDING or HANDSHAKE: waiting for the RTT packet), oldest + first.""" + waiting = (RNS.Link.PENDING, RNS.Link.HANDSHAKE) + links = [link for link in list(getattr(destination, "links", None) or []) + if getattr(link, "status", None) in waiting] + # destination.links is in arrival order; Link.request_time (set by validate_request) confirms it + return sorted(links, key=lambda link: getattr(link, "request_time", None) or 0.0) + + +def half_open_to_close(links: list, interface=None): + """Of half-open links (oldest first), the one to close to make room for a request arriving on interface: + the oldest on the interface holding the most (the request's own on a tie), so a flood arriving on one + interface closes its own links before anyone else's.""" + if not links: + return None + held = Counter(id(getattr(link, "attached_interface", None)) for link in links) + most = max(held.values()) + busiest = id(interface) if held.get(id(interface)) == most else None + return next(link for link in links if held[id(getattr(link, "attached_interface", None))] == most + and busiest in (None, id(getattr(link, "attached_interface", None)))) + + +class HalfOpenLimit: + """Wraps one destination's incoming_link_request to keep its half-open links below limit (see the module).""" + + def __init__(self, destination, limit: int, clock=time.monotonic): + """Guard destination at limit half-open links (at least 1), wrapping its current incoming_link_request.""" + self.destination = destination + self.limit = max(1, int(limit)) + self.clock = clock + self.original = destination.incoming_link_request + self.lock = threading.Lock() + self.closed = 0 # half-open links closed to make room, in all + self.logged_at = None + self.logged_closed = 0 + + def incoming_link_request(self, data, packet): + """RNS's Destination.incoming_link_request, after closing the oldest half-open links over the limit. + + Runs on RNS's inbound packet thread, so it only does quick work: nothing here waits on established + links' processing. + """ + interface = getattr(packet, "receiving_interface", None) + with self.lock: + waiting = half_open_links(self.destination) + while len(waiting) >= self.limit: + link = half_open_to_close(waiting, interface) + waiting.remove(link) + self._close(link) + return self.original(data, packet) + + def _close(self, link): + """Close a half-open link. RNS's teardown() sends the initiator a close packet, so an honest one whose + handshake was cut short learns at once and can try again.""" + try: + link.teardown() + except Exception as e: + log(f"error closing a half-open link: {e}", LOG_WARNING) + return + self.closed += 1 + now = self.clock() + if self.logged_at is None or now - self.logged_at >= HALF_OPEN_LOG_INTERVAL: + log(f"too many incoming links waiting to complete their handshake: closed " + f"{plural(self.closed - self.logged_closed, 'half-open link')}, oldest first", LOG_WARNING) + self.logged_at, self.logged_closed = now, self.closed + + +def limit_half_open(destination, limit: int) -> HalfOpenLimit | None: + """Keep destination's half-open incoming links below limit (see the module). Idempotent: a destination + already guarded keeps its guard, with the new limit. None if destination has no incoming_link_request (an + RNS that changed, or a test's fake).""" + current = getattr(destination, "incoming_link_request", None) + guard = getattr(current, "__self__", None) + if isinstance(guard, HalfOpenLimit): + guard.limit = max(1, int(limit)) + return guard + if not callable(current): + return None + guard = HalfOpenLimit(destination, limit) + destination.incoming_link_request = guard.incoming_link_request + return guard diff --git a/rnfs/network/server.py b/rnfs/network/server.py index 00f3ab9..8e9af61 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -531,6 +531,8 @@ def __init__(self, destination: RNS.Destination, config: Config, backend: Backen RNS.Resource.AUTO_COMPRESS_MAX_SIZE = 0 # Process-wide: reject request Resources and advertisements whose transfer outgrows their size guards.install() + # Links that never finish their handshake are kept in a bucket of their own, below max_incoming_links + guards.limit_half_open(destination, config.max_incoming_links) destination.set_max_request_size(MAX_REQUEST_SIZE) destination.set_link_established_callback(self._link_established) handlers = { diff --git a/specification.md b/specification.md index d54cc62..2b53ab5 100644 --- a/specification.md +++ b/specification.md @@ -161,6 +161,15 @@ pass a `max_response_size` just above their largest honest answer. - At most `max_incoming_links` incoming links. When full, one is closed to make room: the link idle longest with no request in progress, or else a random one with no `/blocks` response in progress. In private mode, links not identified as a member's are chosen first. +- **Half-open links.** RNS validates every link request (a key exchange, a proof, a watchdog thread) and keeps + the link waiting for the initiator's RTT packet for `6 s × hops + 360 s`; only then is it established and + counted above. So, on `rnfs.dht` and on the LXMF delivery destination, links still waiting (RNS `PENDING` + or `HANDSHAKE`) are counted in a bucket of their own, also of `max_incoming_links`. When a link request + arrives with the bucket full, the oldest waiting link on the interface holding the most of them (the + request's own interface on a tie) is closed, rather than the request refused: an honest handshake completes + within about one round trip, so a flood can't keep honest initiators out by keeping the bucket full, and a + flood arriving on one interface closes its own links first. Each request still costs the node the key + exchange and a proof; RNS's own long responder timeout is the root cause. - rnfs neither sends nor accepts compressed Resources. Handlers are registered with `auto_compress=False`, and nodes MUST set RNS's maximum decompressed Resource size (`RNS.Resource.AUTO_COMPRESS_MAX_SIZE` in RNS 1.5.6) to 0, since RNS decompresses before any rnfs check runs. Setting it to 0 also stops RNS @@ -774,7 +783,7 @@ as is cheap. Rules live in the sections referenced. | Routing table takeover with new identities | Only proven-reachable nodes stored; full buckets keep long-lived nodes (6) | | Fake node IDs in replies | Unknown IDs never stored; at most `alpha` path requests for them per lookup; lookup time budget (6) | | Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement protection earned by progress (a per-response grace, then a throughput floor), so a response that transfers nothing can't hold a slot; no-progress and stall cancellation with refund; a cooldown on a link whose response made no progress; requester stall detection (2, 3, 7) | -| Request flooding, link exhaustion | Request size, per-link and global limits; eviction of idle incoming links (3) | +| Request flooding, link exhaustion | Request size, per-link and global limits; eviction of idle incoming links; half-open links capped in their own bucket, oldest closed first (3) | | Decompression and oversized-response bombs | Compressed Resources refused; response sizes capped; multi-segment Resources refused on `rnfs.dht` links and on links to LXMF propagation nodes (3, 7, 13) | | Reading another node's download history | Only provided content is served (7). Under the default `roots`, complete cached downloads are provided; `pinned` keeps them private | | Name rollback or forgery | Self-certifying names; RNS-verified signatures; persisted highest sequence (9) | diff --git a/tests/security/_m1_real_helpers.py b/tests/security/_m1_real_helpers.py new file mode 100644 index 0000000..87bba35 --- /dev/null +++ b/tests/security/_m1_real_helpers.py @@ -0,0 +1,36 @@ +"""Helpers for test_m1_real_rns: a process that floods a node's rnfs.dht with link requests that never complete. + +Run as its own OS process (python3 _m1_real_helpers.py flood CONFIGDIR NODE_ID RATE SECONDS) over the loopback +topology of _h1_real_helpers. Each link request is a genuine RNS one; the flooder never processes the proofs +(Link.validate_proof is replaced), so it never sends the RTT packet and every link stays half-open at the node. +""" +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +from tests.security import _h1_real_helpers as h # noqa: E402 + + +def role_flood(configdir, node_id_hex, rate, seconds): + """Send link requests to the node at rate per second for seconds, never completing one.""" + RNS = h._rns() + RNS.Link.validate_proof = lambda self, packet: None # never answer the proof: no RTT packet is sent + RNS.Reticulum(configdir=os.path.abspath(configdir)) + dest = h.resolve(node_id_hex) + print("FLOOD_READY", flush=True) + sent = 0 + start = time.monotonic() + while time.monotonic() - start < float(seconds): + RNS.Link(dest) + sent += 1 + time.sleep(max(0.0, start + sent / float(rate) - time.monotonic())) + print("FLOOD_DONE", sent, flush=True) + while True: + time.sleep(3600) + + +if __name__ == "__main__": + if sys.argv[1] != "flood": + raise SystemExit(f"unknown role {sys.argv[1]}") + role_flood(*sys.argv[2:6]) diff --git a/tests/security/test_m1_half_open_links.py b/tests/security/test_m1_half_open_links.py new file mode 100644 index 0000000..8e1f6df --- /dev/null +++ b/tests/security/test_m1_half_open_links.py @@ -0,0 +1,207 @@ +"""M1: link requests that never complete were unbounded: RNS validates every link request (key exchange, a proof, +a watchdog thread) and keeps the link half-open for 6 s × hops + 360 s waiting for the RTT packet, while rnfs +only counted links once established. + +Fixed: guards.limit_half_open wraps the destination's incoming_link_request so half-open links (PENDING or +HANDSHAKE) have a capped bucket of their own (max_incoming_links). When it is full, the oldest half-open link on +the interface holding the most is closed, not the new request refused, so a flood can't lock honest initiators +out. Shown with a fake destination that runs incoming_link_request as RNS 1.5.6 does (validate: a new +HANDSHAKE link on the request's interface; append to destination.links). +""" +import types +import unittest +from unittest import mock + +import RNS + +from rnfs.config import Config +from rnfs.network import guards, server +from tests.support import FakeClock +from tests.test_server import FakeBackend, FakeDestination + + +class FakeHalfOpenLink: + """An incoming RNS.Link just validated: HANDSHAKE on the interface its request arrived on.""" + + def __init__(self, destination, interface, request_time: float): + """Waiting for its RTT packet.""" + self.destination = destination + self.status = RNS.Link.HANDSHAKE + self.attached_interface = interface + self.request_time = request_time + self.closed_by_teardown = False + + def activate(self): + """The RTT packet arrived (Link.rtt_packet).""" + self.status = RNS.Link.ACTIVE + + def teardown(self): + """Close like RNS: CLOSED, then link_closed removes it from its destination's links.""" + if self.status == RNS.Link.CLOSED: + return + self.status = RNS.Link.CLOSED + self.closed_by_teardown = True + if self in self.destination.links: + self.destination.links.remove(self) + + +class FakeLinkDestination: + """An IN destination whose incoming_link_request does what RNS 1.5.6's does with a valid request.""" + + def __init__(self): + """No links yet.""" + self.links = [] + self.now = 0.0 + self.validated = 0 + + def incoming_link_request(self, data, packet): + """Link.validate_request (a new half-open link), then self.links.append(link).""" + self.now += 0.01 + self.validated += 1 + link = FakeHalfOpenLink(self, packet.receiving_interface, self.now) + self.links.append(link) + + def receive(self, packet): + """Destination.receive for a LINKREQUEST packet: through self.incoming_link_request.""" + self.incoming_link_request(packet.data, packet) + + def request(self, interface) -> FakeHalfOpenLink: + """A link request arriving on interface; the half-open link it made.""" + self.receive(types.SimpleNamespace(data=b"lr", receiving_interface=interface)) + return self.links[-1] + + def half_open(self) -> list: + """Links still waiting for their RTT packet.""" + return [link for link in self.links if link.status in (RNS.Link.PENDING, RNS.Link.HANDSHAKE)] + + +class HalfOpenLimitTest(unittest.TestCase): + """The half-open bucket: capped, oldest closed first, per interface, established links untouched.""" + + def setUp(self): + self.destination = FakeLinkDestination() + self.tcp, self.lora = object(), object() + + def test_unguarded_half_open_links_are_unbounded(self): + # The finding, as RNS behaves on its own + for _ in range(1000): + self.destination.request(self.tcp) + self.assertEqual(len(self.destination.half_open()), 1000) + + def test_flood_stays_within_the_cap(self): + guards.limit_half_open(self.destination, 16) + flood = [] + for _ in range(1000): + flood.append(self.destination.request(self.tcp)) + self.assertLessEqual(len(self.destination.half_open()), 16) + self.assertEqual(self.destination.validated, 1000) # every request still answered: none refused + self.assertEqual(sum(link.closed_by_teardown for link in flood), 1000 - 16) + self.assertEqual(self.destination.links, flood[-16:]) # the newest kept, the oldest closed + + def test_honest_handshake_completing_within_the_cap_survives_a_flood(self): + guards.limit_half_open(self.destination, 16) + for _ in range(50): + self.destination.request(self.tcp) + honest = self.destination.request(self.tcp) + for _ in range(15): # an honest RTT packet arrives within about one round trip + self.destination.request(self.tcp) + honest.activate() + for _ in range(1000): + self.destination.request(self.tcp) + self.assertEqual(honest.status, RNS.Link.ACTIVE) + self.assertIn(honest, self.destination.links) + self.assertLessEqual(len(self.destination.half_open()), 16) + + def test_established_links_are_neither_counted_nor_closed(self): + guards.limit_half_open(self.destination, 4) + established = [] + for _ in range(40): # each completes before the next request + established.append(self.destination.request(self.tcp)) + established[-1].activate() + # 40 links requested one after another, none closed: established links don't count + self.assertFalse(any(link.closed_by_teardown for link in established)) + for _ in range(100): + self.destination.request(self.tcp) + self.assertTrue(all(link.status == RNS.Link.ACTIVE for link in established)) + self.assertEqual(len(self.destination.half_open()), 4) + + def test_a_flood_on_one_interface_closes_its_own_links_first(self): + guards.limit_half_open(self.destination, 16) + honest = self.destination.request(self.lora) # a slow handshake on another interface + for _ in range(5000): + self.destination.request(self.tcp) + self.assertEqual(honest.status, RNS.Link.HANDSHAKE) # still waiting, not closed + self.assertIn(honest, self.destination.half_open()) + self.assertLessEqual(len(self.destination.half_open()), 16) + + def test_the_interface_holding_most_gives_way_to_another_interfaces_newcomer(self): + guards.limit_half_open(self.destination, 5) + other = [self.destination.request(self.lora) for _ in range(2)] + for _ in range(20): # on a tie the newcomer's own interface gives way, so the two LoRa links stay + self.destination.request(self.tcp) + self.assertTrue(all(link.status == RNS.Link.HANDSHAKE for link in other)) + # A new request on the quieter interface, with the bucket full: the busier one gives way + self.destination.request(self.lora) + on_lora = [link for link in self.destination.half_open() if link.attached_interface is self.lora] + self.assertEqual(len(on_lora), 3) + + def test_half_open_to_close_picks_the_oldest_on_the_busiest_interface(self): + def link(interface, at): + return FakeHalfOpenLink(self.destination, interface, at) + a1, b1, b2 = link(self.tcp, 1), link(self.lora, 2), link(self.lora, 3) + a2, b3 = link(self.tcp, 4), link(self.lora, 5) + self.assertIs(guards.half_open_to_close([a1, b1, b2, a2, b3]), b1) + self.assertIs(guards.half_open_to_close([a1, b1, b2, a2, b3], self.tcp), b1) + self.assertIs(guards.half_open_to_close([a1, b1, a2, b3]), a1) # a tie: the oldest + self.assertIs(guards.half_open_to_close([a1, b1, a2, b3], self.lora), b1) # a tie: the newcomer's own + self.assertIsNone(guards.half_open_to_close([])) + + def test_idempotent_and_the_limit_can_change(self): + first = guards.limit_half_open(self.destination, 8) + second = guards.limit_half_open(self.destination, 4) + self.assertIs(first, second) + self.assertEqual(first.limit, 4) + for _ in range(20): + self.destination.request(self.tcp) + self.assertEqual(len(self.destination.half_open()), 4) + self.assertEqual(first.closed, 16) + + def test_a_destination_without_the_hook_is_left_alone(self): + destination = FakeDestination() # tests/test_server.py's: no incoming_link_request (a changed RNS) + self.assertIsNone(guards.limit_half_open(destination, 8)) + self.assertFalse(hasattr(destination, "incoming_link_request")) + + def test_a_link_that_fails_to_close_does_not_stop_requests(self): + guard = guards.limit_half_open(self.destination, 2) + stuck = self.destination.request(self.tcp) + + def broken(): + """An RNS whose teardown fails.""" + raise RuntimeError("RNS changed") + stuck.teardown = broken + self.destination.request(self.tcp) + with mock.patch.object(guards, "log"): + self.destination.request(self.tcp) + self.assertEqual(self.destination.validated, 3) # the new request was still validated + self.assertEqual(guard.closed, 0) + + def test_request_server_guards_rnfs_dht(self): + class Destination(FakeDestination, FakeLinkDestination): + """tests/test_server.py's fake destination, taking link requests as RNS does.""" + + def __init__(self): + FakeDestination.__init__(self) + FakeLinkDestination.__init__(self) + + destination = Destination() + saved = RNS.Resource.AUTO_COMPRESS_MAX_SIZE + self.addCleanup(setattr, RNS.Resource, "AUTO_COMPRESS_MAX_SIZE", saved) + server.RequestServer(destination, Config(max_incoming_links=8), FakeBackend(), clock=FakeClock(), + start_monitor=False) + for _ in range(100): + destination.request(self.tcp) + self.assertEqual(len(destination.half_open()), 8) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_m1_real_rns.py b/tests/security/test_m1_real_rns.py new file mode 100644 index 0000000..962231a --- /dev/null +++ b/tests/security/test_m1_real_rns.py @@ -0,0 +1,108 @@ +"""M1 over a real Reticulum network: a burst of link requests that never complete stays bounded at a real rnfs +daemon, while an honest link still establishes and is served. + +Same loopback topology as test_h1_real_rns (a TCP hub, a real rnfs daemon serving one 64 KiB public CID, every +other party a separate OS process), with max_incoming_links = 16, which is also the half-open cap. A flooder +process sends genuine RNS link requests at FLOOD_RATE per second for FLOOD_SECONDS and never answers a proof +(see _m1_real_helpers). Each half-open link costs the daemon a watchdog thread, so its thread count shows how +many it holds: without the guard it grows by one per request (FLOOD_RATE × FLOOD_SECONDS, RNS keeps them for +366 s); with it, by at most the cap plus the links closed in the last WATCHDOG_MAX_SLEEP (5 s), whose watchdog +threads haven't woken to exit yet. Linux only (/proc). + +Opt in with RNFS_E2E=1 (real daemons and RNS networking), like tests/test_e2e.py. +""" +import os +import sys +import threading +import time +import unittest +from unittest import mock + +from tests import support +from tests.security import _h1_real_helpers as helpers +from tests.security import _m1_real_helpers +from tests.security import test_h1_real_rns as h1 # not the class itself, which discovery would run again + +CAP = 16 +FLOOD_RATE = 10 # link requests per second +FLOOD_SECONDS = 30 +LINGER = 6 # seconds a closed half-open link's watchdog thread may still be asleep (WATCHDOG_MAX_SLEEP = 5) +SLACK = 30 # other threads the daemon may start meanwhile + + +def threads(pid: int) -> int | None: + """Number of threads of process pid, or None if it can't be read.""" + try: + with open(f"/proc/{pid}/status") as f: + for line in f: + if line.startswith("Threads:"): + return int(line.split()[1]) + except OSError: + return None + return None + + +@unittest.skipUnless(os.environ.get("RNFS_E2E") == "1", "set RNFS_E2E=1 to run") +@unittest.skipUnless(os.path.exists(f"/proc/{os.getpid()}/status"), "needs /proc to count threads") +class M1RealRnsTest(unittest.TestCase): + """Half-open links at a real rnfs daemon stay within their cap under a flood of link requests.""" + + _spawn = h1.H1RealRnsTest._spawn + _status = h1.H1RealRnsTest._status + _stop_all = h1.H1RealRnsTest._stop_all + _honest = h1.H1RealRnsTest._honest + + def setUp(self): + with mock.patch.object(helpers, "VICTIM_TOML", helpers.VICTIM_TOML + f"max_incoming_links = {CAP}\n"): + h1.H1RealRnsTest.setUp(self) + self.victim = next(p for p in self.procs if "daemon" in p.args) + + def test_link_requests_that_never_complete_stay_bounded(self): + baseline = self._honest("honest_baseline", 4) + self.assertGreater(baseline["served"], 0, f"baseline should serve blocks: {baseline}") + before = threads(self.victim.pid) + self.assertIsNotNone(before) + + flood_dir = os.path.join(self.base, "flood") + helpers.write(os.path.join(flood_dir, "config"), helpers.CLIENT_CONFIG.format(port=self.tcp_port)) + flood_log = os.path.join(self.base, "flood.log") + self._spawn([sys.executable, _m1_real_helpers.__file__, "flood", flood_dir, self.node_id, + str(FLOOD_RATE), str(FLOOD_SECONDS)], "flood", env={**os.environ, "PYTHONUNBUFFERED": "1"}) + + def logged(marker): + with open(flood_log, "rb") as f: + return marker in f.read() + support.wait_for(lambda: logged(b"FLOOD_READY"), 90, "the flooder to find the node", interval=0.5) + + peak = [before] + stop = threading.Event() + + def sample(): + while not stop.is_set(): + count = threads(self.victim.pid) + if count is not None: + peak[0] = max(peak[0], count) + stop.wait(0.2) + sampler = threading.Thread(target=sample, daemon=True) + sampler.start() + try: + time.sleep(FLOOD_SECONDS / 2) + # Mid-flood, an honest requester still establishes a link and is served + attack = self._honest("honest_attack", 6) + support.wait_for(lambda: logged(b"FLOOD_DONE"), FLOOD_SECONDS + 60, "the flood to finish", interval=0.5) + finally: + stop.set() + sampler.join(5) + + unguarded = FLOOD_RATE * FLOOD_SECONDS + bound = CAP + LINGER * FLOOD_RATE + SLACK + grew = peak[0] - before + print(f"\nM1 real RNS: victim threads {before} -> peak {peak[0]} (+{grew}); bound +{bound}; " + f"unguarded up to +{unguarded}; honest during the flood: {attack}", file=sys.stderr) + self.assertGreater(attack["served"], 0, f"an honest requester was locked out by the flood: {attack}") + self.assertLessEqual(grew, bound, f"M1: half-open links weren't bounded: the daemon gained {grew} threads " + f"from {unguarded} link requests that never completed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_rns_compat_security.py b/tests/security/test_rns_compat_security.py index e2935bb..9b6ccc0 100644 --- a/tests/security/test_rns_compat_security.py +++ b/tests/security/test_rns_compat_security.py @@ -1,10 +1,15 @@ -"""The RNS internals the /blocks slot fairness fix (H1) relies on are still present in the installed RNS. +"""The RNS internals the /blocks slot fairness fix (H1) and the half-open link guard (M1) rely on are still +present in the installed RNS. server.py's displacement protection measures a per-response grace from the receiving interface's bitrate and the requester's hop count, read through server.link_path(). Those are RNS object attributes that the version number doesn't promise to keep, so, like tests/test_rns_compat.py (another session's file, not edited here), this checks each one and fails naming what changed rather than letting the grace silently fall back. +guards.limit_half_open replaces a destination's incoming_link_request and reads Destination.links and each +link's status, attached_interface and request_time, and closes links with Link.teardown(); those are checked +the same way, with one functional check against a real (offline) RNS.Destination. + Written against RNS 1.5.6. Nothing here starts Reticulum. """ import ast @@ -82,6 +87,69 @@ class _Link: self.assertEqual(server.progress_floor(None, None, config), config.unmeasured_bitrate / 8) +class RnsHalfOpenLinkInternalsTest(unittest.TestCase): + """Each RNS attribute and method guards.limit_half_open (M1) hooks or reads is present and behaves as rnfs + expects.""" + + def test_destination_receive_calls_incoming_link_request_on_the_instance(self): + # The guard replaces destination.incoming_link_request on the instance; RNS must look it up on self + self.assertIn("self.incoming_link_request(plaintext,packet)", _normal_source(RNS.Destination.receive)) + self.assertEqual(list(inspect.signature(RNS.Destination.incoming_link_request).parameters), + ["self", "data", "packet"]) + + def test_incoming_link_request_validates_and_appends_to_links(self): + # half_open_links reads destination.links, which RNS appends each validated link to, in arrival order + source = _normal_source(RNS.Destination.incoming_link_request) + self.assertIn("RNS.Link.validate_request(self,data,packet)", source) + self.assertIn("self.links.append(link)", source) + + def test_validate_request_leaves_a_half_open_link_with_its_interface_and_request_time(self): + source = _normal_source(RNS.Link.validate_request) + for expected in ("link.handshake()", "link.attached_interface=packet.receiving_interface", + "link.request_time=time.time()", "link.start_watchdog()"): + self.assertIn(expected, source) + # handshake() moves an incoming link from PENDING to HANDSHAKE; the RTT packet makes it ACTIVE + self.assertIn("self.status=Link.HANDSHAKE", _normal_source(RNS.Link.handshake)) + self.assertIn("self.status=Link.ACTIVE", _normal_source(RNS.Link.rtt_packet)) + states = {RNS.Link.PENDING, RNS.Link.HANDSHAKE, RNS.Link.ACTIVE, RNS.Link.STALE, RNS.Link.CLOSED} + self.assertEqual(len(states), 5) + + def test_teardown_closes_a_half_open_link_and_removes_it_from_its_destination(self): + # The guard closes with Link.teardown(): on a HANDSHAKE link it sends the initiator a close packet (the + # link keys exist after handshake()), marks it CLOSED and runs link_closed, which removes it from + # destination.links, so the guard's count drops at once + teardown = _normal_source(RNS.Link.teardown) + self.assertIn("ifself.status!=Link.PENDINGandself.status!=Link.CLOSED:self.__teardown_packet()", teardown) + self.assertIn("self.link_closed()", teardown) + self.assertIn("self.destination.links.remove(self)", _normal_source(RNS.Link.link_closed)) + # Its watchdog thread wakes at least every WATCHDOG_MAX_SLEEP seconds and ends once the link is CLOSED + self.assertLessEqual(RNS.Link.WATCHDOG_MAX_SLEEP, 5) + self.assertIn("whilenotself.status==Link.CLOSED:", _normal_source(RNS.Link._Link__watchdog_job)) + + def test_guard_runs_for_a_link_request_to_a_real_destination(self): + from unittest import mock + + from rnfs.network import guards + with mock.patch.object(RNS.Transport, "register_destination"): + destination = RNS.Destination(RNS.Identity(), RNS.Destination.IN, RNS.Destination.SINGLE, "rnfs", + "compat") + guard = guards.limit_half_open(destination, 4) + calls = [] + original = guard.original + guard.original = lambda data, packet: calls.append(data) or original(data, packet) + packet = mock.Mock(packet_type=RNS.Packet.LINKREQUEST, data=b"too short to be a link request", + receiving_interface=None) + destination.receive(packet) + self.assertEqual(calls, [b"too short to be a link request"]) # through the guard, then RNS's own + self.assertEqual(destination.links, []) # RNS rejected the malformed request as before + self.assertEqual(guards.half_open_links(destination), []) + + +def _normal_source(function) -> str: + """function's source without whitespace and with single quotes.""" + return _normal(textwrap.dedent(_source_or_empty(function))) + + def _source_or_empty(function) -> str: """function's source, or "" if it can't be read.""" try: From c8d09ad0a4b149b72d9892d57b595665cb3ce526 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:26:57 +1030 Subject: [PATCH 035/107] Update the real-RNS H1 attacker to request one CID, since a repeated CID is now refused --- tests/security/_h1_real_helpers.py | 2 +- tests/security/test_h1_real_rns.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/security/_h1_real_helpers.py b/tests/security/_h1_real_helpers.py index 0f82360..f434c5b 100644 --- a/tests/security/_h1_real_helpers.py +++ b/tests/security/_h1_real_helpers.py @@ -167,7 +167,7 @@ def role_attacker(configdir, node_id_hex, cid, n_links=8, rtt_seconds=3600.0, po RNS.Resource.accept = staticmethod(lambda *a, **k: None) # never download a response Resource RNS.Reticulum(configdir=os.path.abspath(configdir)) dest = resolve(node_id_hex) - cids = [cid_binary(cid)] * 15 # duplicates -> a ~960 KiB response, sent as a held Resource + cids = [cid_binary(cid)] # one CID, a 64 KiB Resource: a request repeating a CID is refused as malformed (L2) links = [] for _ in range(int(n_links)): link = open_link(dest) diff --git a/tests/security/test_h1_real_rns.py b/tests/security/test_h1_real_rns.py index e150f6d..37c0fa8 100644 --- a/tests/security/test_h1_real_rns.py +++ b/tests/security/test_h1_real_rns.py @@ -6,12 +6,11 @@ The attacker opens a handful of links, inflates each link's rtt (so a response it never downloads stays ADVERTISED, holding its slot, since RNS times an advertised Resource out after link.rtt * 6), and keeps -firing /blocks for 15 copies of the CID (a ~960 KiB Resource -- duplicates are kept, see L2). With only two -fast-pool slots by default (max_blocks_responses = 4), both fill and every honest request is refused with -"every fast-link response slot is busy". +firing /blocks for the CID (a 64 KiB Resource; a request repeating a CID is refused as malformed, see L2). With +only two fast-pool slots by default (max_blocks_responses = 4), both filled and, before the H1 fix, every honest +request was refused with "every fast-link response slot is busy". -This test asserts the SAFE behaviour -- that an honest requester keeps being served while the attacker runs --- so it fails until H1 is fixed. See @unittest.expectedFailure below. +This test asserts the SAFE behaviour -- that an honest requester keeps being served while the attacker runs. Opt in with RNFS_E2E=1 (real daemons and RNS networking), like tests/test_e2e.py. """ @@ -134,7 +133,7 @@ def ready(): support.wait_for(ready, 90, "attacker to hold the slots", interval=0.5) time.sleep(8) # let the held responses settle into both fast-pool slots - # Safe behaviour: the honest requester should still be served. It is not -- H1. + # Safe behaviour: the honest requester should still be served. attack = self._honest("honest_attack", 20) self.assertLessEqual( attack["refusal_rate"], SAFE_REFUSAL_RATE, From 4c8d65165bed602edacd2fe67e6f161f59b37ee8 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:26:57 +1030 Subject: [PATCH 036/107] Document the measured link-churn residual on the /blocks fast pool --- specification.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/specification.md b/specification.md index 2b53ab5..e50a0b8 100644 --- a/specification.md +++ b/specification.md @@ -844,12 +844,19 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. - An attacker that behaves exactly like a slow-link client competes fairly for the slow pool's slots, and can't be told apart from one. - Displacement protection and the cooldown stop a requester holding a response slot without transferring, and - the cooldown binds a link and its identity. A flood of fresh unidentified links that each take a slot, - transfer nothing, and are replaced before their cooldown matters can still keep a pool busy; each counts as - its own requester, so the cooldown doesn't bind them. Where all such links arrive over one interface - (a single upstream transport node), the per-interface rule can't separate them from honest requesters on - the same interface either. The hold is no longer free — the attacker must keep opening links, which incoming - link admission and RNS link establishment bound — but the fast pool can still be denied this way. + the cooldown binds a link and its identity. But each unidentified link is its own requester, so many links + that each take a slot, transfer nothing for one grace, and then sit out their cooldown can still keep a pool + busy: either fresh links, at about one per grace per slot (0.7 a second for the two fast slots over TCP, + whose grace is 3 s), or a fixed set of about `slots × (blocks_cooldown + grace) / grace` links (42 at the + defaults, within `max_incoming_links`), used in turn. Where they all arrive over one interface (a single + upstream transport node), nothing at admission tells them from honest requesters on the same interface: a + newcomer with no history looks the same either way. Limits on young links or on new links per interface + don't bind the fixed set and would hold back honest peers reaching the node through one transport node; a + waiting list giving freed slots to the requester refused longest ago only shares slots per link, which the + attacker multiplies (measured over real RNS: no change, honest refusal 0.95–1.0 either way). So the fast + pool can still be denied this way. The hold is no longer free: the attacker must keep links open and + polling, or keep opening them. An operator under such an attack can raise `blocks_cooldown`, so that a fixed + set would need more links than `max_incoming_links` admits, leaving only fresh links to do it. - An outside web site can frame a gateway page and receive public content from it by `postMessage` (outside private mode, only content public by CID). From c2c36b639257f1fcc217f550625d1706eff25538 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:27:07 +1030 Subject: [PATCH 037/107] Note the /blocks budget cap, budget charging, half-open links and the churn limit in the changelog --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ca0ed..1ce8666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,21 @@ Serving `/blocks` under load: holds no slot in a full pool may displace a zero-progress response from an interface holding two or more. - A `/blocks` request that repeats a CID is refused as malformed, so a node can't be made to read and serve the same block several times in one response. +- On a link with no rate estimate a `/blocks` response holds at most `CHUNK_SIZE` of blocks after the first + (which is always served): what requesters already ask for there. It used to be whatever `max_bytes` the + requester sent, so one packet could ask for a ~1 MiB response with a deadline of many hours. +- `upload_budget` is charged as a response is sent, from its Resource's progress, instead of its full size up + front with a refund at the end. A response is charged about one packet when it starts, and the rest of it is + set aside only while it is within its grace or moving, so responses a requester never downloads (each + unidentified link counting as its own requester) no longer hold the budget. A completed response costs its + size and a cancelled one what it sent, as before. The half-per-requester rule now caps what one requester's + responses in flight may cost. +- Link requests that never complete their handshake are counted in a bucket of their own, of + `max_incoming_links`, on `rnfs.dht` and the LXMF delivery destination. When it is full the oldest one on the + busiest interface is closed. RNS keeps such links (each with a thread) for at least 366 s and rnfs only saw + links once established, so a flood of link requests could exhaust the node's memory or threads. +- Known limit: many unidentified links that each hold a slot for one grace, then sit out their cooldown, can + still deny the fast pool over a shared interface (specification section 10). ## 0.1.3 From 3265621d179f120549929c5861c3d9d21bfcd362 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:30:45 +1030 Subject: [PATCH 038/107] Answer every page request and HEAD for ?format=raw with 409, as for archives --- CHANGELOG.md | 4 + README.md | 4 +- design.md | 9 ++- rnfs/gateway.py | 2 +- rnfs/gateway_formats.py | 45 +++++------ specification.md | 22 ++--- .../test_l6_gateway_storage_oracle.py | 80 ++++++++++++++----- 7 files changed, 109 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ecc2..ecdb684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ Gateway privacy: - `?format=archive` answers every web page's request and every `HEAD` with `409`, stored or not. It used to answer `200` for content stored on the node, which let any web site tell what the user had downloaded or viewed (an iframe of a download fires no load event). The user's own `GET` is unchanged. +- `?format=raw` answers every web page's request and every `HEAD` with `409` too, stored or not, and so never + takes a `gateway_fetches` slot. A page used to get the block, a download, if it was stored, but a network + fetch otherwise (an error page if nobody had it, or `503` once the page held every slot), which told it the + same. The user's own `GET` is unchanged. - A web page's `HEAD`, or range not starting at byte 0, takes its Content-Type from the file's first bytes, fetched if need be, as its `GET` does. It used to sniff them only if stored (else `application/octet-stream`), which a page may be able to observe (e.g. through opaque-response blocking). diff --git a/README.md b/README.md index 1fb19d0..efe367d 100644 --- a/README.md +++ b/README.md @@ -768,8 +768,8 @@ Nicks are 1–32 characters of `a-z 0-9 - _ .`, starting with a letter or digit. - `?filename=` sets the type and the download name. - `/rnfs/?format=raw` returns the block, and `/rnfs/[/path]?format=archive` returns an archive you can `rnfs import`. You can check both against the CID without trusting the gateway. A web page's - request or a `HEAD` never gets an archive (409), so pages can't tell what your node stores; opening the - URL yourself fetches what isn't stored first. + request or a `HEAD` never gets either (409), so pages can't tell what your node stores; opening the URL + yourself fetches what isn't stored first. - Pages are sandboxed: they can show files of their own content (images, scripts, style sheets), but no page can read gateway responses with `fetch()`. Sealed files are never decrypted by the gateway. diff --git a/design.md b/design.md index 99d40db..0c1471f 100644 --- a/design.md +++ b/design.md @@ -205,7 +205,7 @@ out within `link_idle_timeout`. | `info(target, for_gateway=False, from_node=None, raw=False)` | `{"cid", "type": "file"\|"directory", "size", "executable"}` (+ `"sealed", "recipient", "stored_size", "name"`; the sealed name is None unless `safe_default_name` accepts it) | | `stored_head(target, length)` | a file's first `length` bytes if stored, else None; never fetches (the user's own gateway `HEAD` and ranges not at 0) | | `read(target, offset=0, length=None, for_gateway=False, from_node=None, raw=False)` | iterator of bytes of a file (range fetched only; sealed files decrypted unless `raw` or `for_gateway`) | -| `block(cid, for_gateway=False)` | the CID's own block, verified (`cat --block`, `?format=raw`) | +| `block(cid, for_gateway=False)` | the CID's own block, verified (`cat --block`, `?format=raw`: the gateway calls it only for the user's own GET) | | `export_stream(target, start, for_gateway=False)` | fetches the DAG, then writes its archive to `start(exact length)` (`?format=archive`; `None` for HEAD). The gateway calls it only for the user's own GET (never for a limited request or a HEAD, stored or not), inside `fetcher.observing(ClientWatch)`, which cancels the fetch once the client disconnects | | `ls(target, for_gateway=False)` | `[{"name", "kind": "file"\|"executable"\|"directory", "size", "cid"}]` | | `stat(target)` | `{"cid", "size", "blocks", "local_blocks", "local_bytes"}` | @@ -282,9 +282,10 @@ possible future way to let pages read their own DAG. identity hash; the node's own identity; contacts), `open_sealed` over a Fetch's `read_range`, `sealed_info`, `write_sealed`, `block`, `export_stream`, and the API option parser `add_options`. - `gateway_formats.py` — `?format=raw|archive`; `archive.archive_size` gives the exact length, so the archive - is sent with a Content-Length before streaming. A web page's request or a HEAD always gets 409, stored or - not (a 200 for stored targets only told pages what is stored: L6), so archives never take a - `gateway_fetches` slot; a bare-CID `If-None-Match` revalidation answers 304 first. + is sent with a Content-Length before streaming. For every format, a web page's request or a HEAD always + gets 409, stored or not (a 200 download for stored content only told pages what is stored: L6), before the + node is asked anything, so formats never take a `gateway_fetches` slot; a bare-CID `If-None-Match` + revalidation answers 304 first. - `content/seal.py` rejects a final segment of more than 65536 plaintext bytes, and `SealedFile.name` is None for a name that fails its tag, isn't UTF-8 or isn't a valid name. `write_sealed` reads the name before creating the output, so nothing raised there leaves a file. `open_sealed` reports the stored (ciphertext) diff --git a/rnfs/gateway.py b/rnfs/gateway.py index 863e36c..76de215 100644 --- a/rnfs/gateway.py +++ b/rnfs/gateway.py @@ -413,7 +413,7 @@ def serve(self): raise GatewayError(404, "gateway paths are /rnfs/[/path] and /name/[/path]") slash = len(segments) > 3 and segments[-1] == "" names = [self.decode_segment(segment) for segment in (segments[3:-1] if slash else segments[3:])] - if self.param("format") is not None: # --- content: trustless ?format=raw|archive --- + if self.param("format") is not None: # --- trustless ?format=raw|archive: the user's own GET only --- return gateway_formats.serve_format(self, segments, names) if segments[1] == "rnfs": diff --git a/rnfs/gateway_formats.py b/rnfs/gateway_formats.py index 3a67a4a..053609b 100644 --- a/rnfs/gateway_formats.py +++ b/rnfs/gateway_formats.py @@ -8,16 +8,18 @@ blocks are checked by `rnfs import` (or any reader) from the root down. Every gateway header rule still applies (the handler's end_headers adds nosniff and the CSP, and no CORS header); both are immutable, with an ETag naming the target and the format (".raw", ".archive") so they never collide with the -plain response's "". Fetches count against gateway_fetches like any other, and archives keep -max_download_size and max_output_entries. /name/ paths don't take ?format= (the result must be named by -the CID it is checked against). - -An archive needs the target's whole DAG before its first byte (its length is in the headers), so it is made -only for the user's own GET (handler.limited() false: no Sec-Fetch-Site, or "none"), which fetches what -isn't stored; that fetch is cancelled once the client disconnects. A web page's request or a HEAD always -gets 409, stored or not: nobody waiting for a body shouldn't make the node fetch a whole DAG, and answering -200 for stored targets only would tell web pages what the node stores (an iframe of a 200 with -Content-Disposition attachment becomes a download and fires no load event; a 409 page does). +plain response's "". Archives keep max_download_size and max_output_entries. /name/ paths don't take +?format= (the result must be named by the CID it is checked against). + +Every format is answered only to the user's own GET (handler.limited() false: no Sec-Fetch-Site, or "none"), +which fetches what isn't stored (an archive's fetch is cancelled once the client disconnects). A web page's +request or a HEAD always gets 409, stored or not, before the node is asked anything, so no format ever takes +a gateway_fetches slot. Neither type is one a browser shows, so a format's 200 is a download, and an iframe +of a download fires no load event while an error page does: answering a page 200 for stored content but a +fetch (an error once nobody has it, or 503 once the page holds every gateway_fetches slot) for the rest +would tell web pages what the node stores (L6). A bare-CID If-None-Match revalidation, which needs nothing +stored, is answered 304 first. An archive also needs the target's whole DAG before its first byte (its +length is in the headers), and a request whose body nobody reads shouldn't make the node fetch a whole DAG. """ import select import socket @@ -28,6 +30,7 @@ RAW_TYPE = "application/vnd.rnfs.raw" ARCHIVE_TYPE = "application/vnd.rnfs.archive" FORMATS = ("raw", "archive") +TOOLS = {"raw": "`rnfs cat --block`", "archive": "`rnfs export`"} # for the 409's message def serve_format(handler, segments: list[str], names: list[str]): @@ -47,22 +50,18 @@ def serve_format(handler, segments: list[str], names: list[str]): raise GatewayError(400, "?format=raw is only for a bare CID: request the CID inside the path itself") target = "/".join([root, *names]) + if not names and etag_matches(handler.headers.get("If-None-Match"), f'"{root}.{fmt}"'): + return _not_modified(handler, f'"{root}.{fmt}"', IMMUTABLE) # a revalidation needs nothing stored + if handler.limited() or handler.command == "HEAD": + # Whether the target is stored or not: the answer must not tell a web page which it is (L6) + raise GatewayError(409, f"?format={fmt} is only answered to a GET of the user's own, never to a web page's " + "request or a HEAD, whether the content is stored or not: open the URL directly, " + f"or use {TOOLS[fmt]}") + if fmt == "raw": - etag = f'"{root}.raw"' - if etag_matches(handler.headers.get("If-None-Match"), etag): - return _not_modified(handler, etag, IMMUTABLE) - if handler.limited() and not handler.node.is_local(root): - handler.need_network() block = handler.node.block(root, for_gateway=True) - return handler.send_body(200, RAW_TYPE, block, {"ETag": etag, "Cache-Control": IMMUTABLE}) + return handler.send_body(200, RAW_TYPE, block, {"ETag": f'"{root}.raw"', "Cache-Control": IMMUTABLE}) - if not names and etag_matches(handler.headers.get("If-None-Match"), f'"{root}.archive"'): - return _not_modified(handler, f'"{root}.archive"', IMMUTABLE) # a revalidation needs nothing stored - if handler.limited() or handler.command == "HEAD": - # Whether the target is stored or not: the answer must not tell a web page which it is - raise GatewayError(409, "an archive is only made for a GET of the user's own, never for a web page's " - "request or a HEAD, whether the content is stored or not: open the URL directly, " - "or use `rnfs export`") if names: cid = handler.node.info(target, for_gateway=True)["cid"] # the ETag names the target, not the root else: diff --git a/specification.md b/specification.md index 1de7246..67a79b7 100644 --- a/specification.md +++ b/specification.md @@ -856,8 +856,8 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. - Mass announces from new identities compete for RNS's announce bandwidth, which RNS itself caps. - Any web page can make the local gateway fetch content (a plain request to `127.0.0.1` passes the Host check), and can time responses to guess what is cached. The gateway's own limits (section 11) bound the - airtime this costs: `gateway_fetches`, and no archives of content not already stored for a web page's - request or a `HEAD`. + airtime this costs: `gateway_fetches`, and no `?format=` response (raw block or archive) at all for a web + page's request or a `HEAD`. - Every local user can use the gateway, and can make it or the API busy by holding connections up to the cap (section 11); the API itself needs the token. - Unidentified links can still be tied to their sender by path and timing, e.g. a publisher's `/find` just @@ -958,14 +958,17 @@ gateway can never reach the API. - `/rnfs/[/path]?format=archive` returns an archive (section 8) of the target, as `application/vnd.rnfs.archive` with `Content-Disposition: attachment; filename=".rnfs-archive"` and an exact `Content-Length`; it is byte-identical to `rnfs export`. Its length must be known before - its first byte, so it needs the whole DAG: a request that isn't the user's own navigation (`Sec-Fetch-Site` - present and not `none`) and any `HEAD` get `409 Conflict`, whether the target is stored or not (an answer - that depended on it would tell web pages what the node stores). The user's own `GET` fetches the whole - DAG first, within `max_download_size` and `max_output_entries` (413 otherwise), and the fetch is - cancelled if the client disconnects. A bare-CID `If-None-Match` revalidation is answered 304 before anything else. + its first byte, so the user's own `GET` fetches the whole DAG first, within `max_download_size` and + `max_output_entries` (413 otherwise), and the fetch is cancelled if the client disconnects. + - Every format is answered only to the user's own `GET`, which fetches what isn't stored. A request that + isn't the user's own navigation (`Sec-Fetch-Site` present and not `none`) and any `HEAD` get + `409 Conflict`, whether the target is stored or not, without the node fetching anything. A browser + downloads both types, and a frame of a download fires no load event while an error page does, so an + answer that depended on what is stored (a download for stored content, a fetch that fails or a `503` for + the rest) would tell web pages what the node stores. A bare-CID `If-None-Match` revalidation is answered + 304 before anything else. Formats therefore never use `gateway_fetches`. Unknown formats get 400. - Both carry `ETag: ".raw"` or `".archive"` and `Cache-Control: immutable` - (`If-None-Match` answers 304), and every other gateway rule (the CSP, nosniff, no CORS headers, and - for `?format=raw` `gateway_fetches`) applies. Unknown formats get 400. + (`If-None-Match` answers 304), and every other gateway rule (the CSP, nosniff, no CORS headers) applies. - **Directories.** - A directory path without a trailing slash redirects to the slash form. - It is served as its `index.html` if present, otherwise as an HTML-escaped listing. @@ -992,6 +995,7 @@ gateway can never reach the API. same answer whether the node stores the content or has to fetch it. The limit is a known, accepted leak: while a page keeps every slot busy with fetches, stored content is still served and content that needs a fetch gets 503, so the page can tell them apart. Like the timing difference, closing it would cost more than it protects. + None of these applies to `?format=`, which a web page's request never gets (409, above). - The gateway never expands contact nicks (`/name//` is not supported), so web pages can't use or probe the address book. diff --git a/tests/security/test_l6_gateway_storage_oracle.py b/tests/security/test_l6_gateway_storage_oracle.py index 65ce81c..0445c82 100644 --- a/tests/security/test_l6_gateway_storage_oracle.py +++ b/tests/security/test_l6_gateway_storage_oracle.py @@ -1,17 +1,21 @@ """L6: the gateway told web pages which CIDs the node stores: `?format=archive` answered a web page's request or a HEAD -with 200 for a stored target and 409 for one not stored, and a HEAD's Content-Type was sniffed from stored first -bytes but application/octet-stream when they weren't. +with 200 for a stored target and 409 for one not stored, `?format=raw` answered a web page with 200 (a download, which +fires no load event) for a stored block and a network fetch for one not stored (an error page, or 503 once the page +held every gateway_fetches slot), and a HEAD's Content-Type was sniffed from stored first bytes but +application/octet-stream when they weren't. Two gateways serve the same content (the same CIDs) from two fake nodes, one storing it and one that has to fetch every block from its "network" each time (its first bytes are never stored, as for a big file whose first chunk -isn't). Every request a web page can start (Sec-Fetch-Site other than "none") and every archive HEAD must get the -same status, headers and body from both. The user's own requests (no Sec-Fetch-Site, or "none") may differ: only -the user sees them. A last test documents what still differs while gateway_fetches is full. +isn't). Every request a web page can start (Sec-Fetch-Site other than "none") and every HEAD of a ?format= must get +the same status, headers and body from both, also for ?format= while the fetch slots are full or the network doesn't +have the content. The user's own requests (no Sec-Fetch-Site, or "none") may differ: only the user sees them. A last +test documents what still differs for plain paths while gateway_fetches is full. """ import json import unittest from rnfs.config import Config +from rnfs.errors import NotFound from rnfs.gateway import GatewayServer from tests.test_api import FakeNode, raw_request @@ -38,11 +42,14 @@ class NetworkNode(FakeNode): def __init__(self): super().__init__() self.fetches = 0 + self.findable = True # False: no provider on the network has the remote CIDs def _fetch(self, cid: str): if cid in self.remote: with self.lock: self.fetches += 1 + if not self.findable: + raise NotFound(f"no provider found for {cid}") def stored_head(self, target, length): self.record("stored_head", target, length) @@ -87,11 +94,24 @@ def setUp(self): self.page, self.data, self.site = page, data, site # the same CIDs on both nodes def paths(self) -> list[str]: - """Every kind of /rnfs/ path: bare files, files by name and sniffed, a site, and both formats.""" + """Every kind of /rnfs/ path: bare files, files by name and sniffed, a site, and every format.""" return [f"/rnfs/{self.page}", f"/rnfs/{self.data}", f"/rnfs/{self.site}/", f"/rnfs/{self.site}/page.html", f"/rnfs/{self.site}/data", f"/rnfs/{self.site}", f"/rnfs/{self.page}?filename=x.bin", - f"/rnfs/{self.page}?format=raw", f"/rnfs/{self.site}?format=archive", - f"/rnfs/{self.site}/page.html?format=archive", f"/rnfs/{self.page}?format=archive"] + *self.format_paths(), f"/rnfs/{self.site}/data?format=raw"] + + def format_paths(self) -> list[str]: + """Every valid ?format= path: a file's and a directory's block, and archives of a root, a path and a file.""" + return [f"/rnfs/{self.page}?format=raw", f"/rnfs/{self.data}?format=raw", f"/rnfs/{self.site}?format=raw", + f"/rnfs/{self.site}?format=archive", f"/rnfs/{self.site}/page.html?format=archive", + f"/rnfs/{self.page}?format=archive"] + + def hold_fetch_slots(self): + """A page holds every gateway_fetches slot of both gateways with fetches that never finish.""" + for node, server in self.gateways.values(): + limiter = server.server.RequestHandlerClass.limiter + for _ in range(limiter.limit): + self.assertTrue(limiter.try_acquire()) + self.addCleanup(lambda limiter=limiter: [limiter.release() for _ in range(limiter.limit)]) def both(self, method: str, path: str, headers: dict) -> dict: """kind → (status, headers without Date, body) of the request to each gateway.""" @@ -124,16 +144,34 @@ def test_a_pages_head_has_the_sniffed_type(self): status, headers, body = self.assert_same("HEAD", path, PAGE_REQUESTS["cross-site image"], path) self.assertEqual((status, dict(headers)["Content-Type"], body), (200, expected, b""), path) - def test_archives_for_pages_and_heads_are_always_409(self): + def test_formats_for_pages_and_heads_are_always_409(self): + # L6: fails until every ?format= (raw as well as archive) answers a page's request and every HEAD with 409 users = {"no Sec-Fetch-Site": {}, "Sec-Fetch-Site none": {"Sec-Fetch-Site": "none"}} - for path in (f"/rnfs/{self.site}?format=archive", f"/rnfs/{self.site}/page.html?format=archive"): + for path in self.format_paths(): for name, headers in PAGE_REQUESTS.items(): for method in ("GET", "HEAD"): - self.assertEqual(self.assert_same(method, path, headers, name)[0], 409, (name, method)) + self.assertEqual(self.assert_same(method, path, headers, name)[0], 409, (name, method, path)) for name, headers in users.items(): # the user's own HEAD - self.assertEqual(self.assert_same("HEAD", path, headers, name)[0], 409, name) + self.assertEqual(self.assert_same("HEAD", path, headers, name)[0], 409, (name, path)) for (node, _) in self.gateways.values(): - self.assertEqual(node.called("export_stream"), []) + self.assertEqual((node.called("block"), node.called("export_stream")), ([], [])) + self.assertEqual(self.gateways["unstored"][0].fetches, 0) # nothing was fetched for them either + + def test_formats_dont_depend_on_storage_while_fetch_slots_are_full(self): + # L6: fails until a page's ?format=raw no longer gets a download if stored and a 503 if not + self.hold_fetch_slots() + for name, headers in PAGE_REQUESTS.items(): + for path in self.format_paths(): + for method in ("GET", "HEAD"): + self.assert_same(method, path, headers, name) + + def test_formats_dont_depend_on_storage_when_the_network_lacks_the_content(self): + # L6: fails until a page's ?format=raw no longer gets a download if stored and an error page if not + self.gateways["unstored"][0].findable = False + for name, headers in PAGE_REQUESTS.items(): + for path in self.format_paths(): + for method in ("GET", "HEAD"): + self.assert_same(method, path, headers, name) def test_users_own_archive_get_still_works(self): # The user's own navigation (Sec-Fetch-Site: none) or tool (no header) gets the archive, fetching it if needed @@ -143,15 +181,21 @@ def test_users_own_archive_get_still_works(self): self.assertEqual((status, body), (200, f"archive of {self.site}".encode())) self.assertGreater(self.gateways["unstored"][0].fetches, fetches) + def test_users_own_raw_get_still_works(self): + # The user's own navigation or tool gets the block, fetching it if needed, even while a page holds every slot + self.hold_fetch_slots() + for headers in ({}, {"Sec-Fetch-Site": "none"}): + for cid, block in ((self.page, PAGE), (self.data, PNG)): + status, response, body = self.assert_same("GET", f"/rnfs/{cid}?format=raw", headers, headers) + self.assertEqual((status, dict(response)["Content-Type"], body), + (200, "application/vnd.rnfs.raw", block)) + self.assertEqual(self.gateways["unstored"][0].fetches, 4) + def test_full_fetch_slots_still_reveal_storage(self): """Accepted residual (specification.md, gateway section): while a page holds every gateway_fetches slot, stored content is still served and content that needs a fetch gets 503. Closing it would break pages or move the leak into timing; this test pins the documented behaviour so a change to it is noticed.""" - for node, server in self.gateways.values(): - limiter = server.server.RequestHandlerClass.limiter - for _ in range(limiter.limit): # a page holds every slot with fetches that never finish - self.assertTrue(limiter.try_acquire()) - self.addCleanup(lambda limiter=limiter: [limiter.release() for _ in range(limiter.limit)]) + self.hold_fetch_slots() responses = self.both("GET", f"/rnfs/{self.data}", PAGE_REQUESTS["cross-site image"]) self.assertEqual((responses["stored"][0], responses["unstored"][0]), (200, 503)) From 67b2843b20f290f1869845c5cabd7c625a67c6f7 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:30:45 +1030 Subject: [PATCH 039/107] Update the gateway format tests for raw blocks answered only to the user's GET --- tests/test_gateway_formats.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_gateway_formats.py b/tests/test_gateway_formats.py index 5a04040..4ec6b30 100644 --- a/tests/test_gateway_formats.py +++ b/tests/test_gateway_formats.py @@ -123,16 +123,21 @@ def test_no_cors_headers(self): self.assertIsNone(self.get(f"/rnfs/{self.root}?format={fmt}", headers)[1] ["Access-Control-Allow-Origin"], headers) - def test_gateway_fetches_limit(self): + def test_raw_blocks_need_a_users_get(self): + """A web page's raw request or a HEAD gets 409 whether the block is stored or not (a 200 download for + stored blocks only would tell pages what is stored: L6), so it never takes a gateway_fetches slot.""" missing = str(Cid.for_block(RAW, b"not stored anywhere")) limiter = self.server.server.RequestHandlerClass.limiter self.assertTrue(limiter.try_acquire()) # the only slot is busy try: - status, headers, _ = self.get(f"/rnfs/{missing}?format=raw", {"Sec-Fetch-Site": "cross-site"}) - self.assertEqual(status, 503) - self.assert_common(headers) - # Stored content is served without limit - self.assertEqual(self.get(f"/rnfs/{self.root}?format=raw", {"Sec-Fetch-Site": "cross-site"})[0], 200) + for cid in (missing, self.root): + for headers, method in (({"Sec-Fetch-Site": "cross-site"}, "GET"), + ({"Sec-Fetch-Site": "same-origin"}, "HEAD"), ({}, "HEAD")): + status, response, _ = self.get(f"/rnfs/{cid}?format=raw", headers, method) + self.assertEqual(status, 409, (cid, headers, method)) + self.assert_common(response) + # The user's own GET is served, and isn't limited + self.assertEqual(self.get(f"/rnfs/{self.root}?format=raw", {"Sec-Fetch-Site": "none"})[0], 200) finally: limiter.release() From c3b4d454b62a11ab93f4ed268c63244e779dcd80 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:33:10 +1030 Subject: [PATCH 040/107] Cancel a re-provided key's queued withdrawals and save where a publish round stores as it goes --- CHANGELOG.md | 8 + design.md | 12 +- rnfs/publisher.py | 83 +++++++-- specification.md | 10 +- .../test_new1_publisher_withdraw_race.py | 176 ++++++++++++++++++ .../test_new6_publisher_crash_cut_round.py | 115 ++++++++++++ 6 files changed, 380 insertions(+), 24 deletions(-) create mode 100644 tests/security/test_new1_publisher_withdraw_race.py create mode 100644 tests/security/test_new6_publisher_crash_cut_round.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ecc2..949c7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,14 @@ DHT and names: - A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own client never sends more. +Publishing, names and storage: +- A CID unpinned and pinned again while a publish round was sending (or evicted and fetched again) could be + withdrawn by the next round from the nodes that had just stored it, and the node's own record of it was + gone too, until its next republish. Providing a key again now cancels its queued withdrawals and records + the node itself again, and a round sends only the withdrawals still queued. +- Where a publish round stored each key is saved as the round goes (every 10 nodes or 30 s), so records stored + by a round cut short by a crash are withdrawn later; they used to stay until they expired. + ## 0.1.3 Joining the DHT: diff --git a/design.md b/design.md index 99d40db..f225a33 100644 --- a/design.md +++ b/design.md @@ -235,7 +235,10 @@ first `refresh()` only after the features have registered their key sources, so kept names' keys. `publish_due` republishes `DhtKey`s every max(effective `republish_interval`, 3 days) with a per-key ±10% jitter (capped below `PROVIDER_TTL / 2`), drops a withdrawal of any key it provides to the same node in that round, and on slow nodes runs at most 10 lookups per round, waiting 30 s × the -round's lookups before the next (never-published items first, then the longest unpublished). +round's lookups before the next (never-published items first, then the longest unpublished). When +`refresh()` finds a key provided again, it cancels that key's queued withdrawals (their nodes join its earlier +nodes) and re-records the node's own record, and a round sends only withdrawals still queued when it reaches +each node, so a withdrawal queued while the key was briefly unprovided never follows a store of it. `protocol.provide_batches` puts withdrawals before stores, and a key in both lists is only stored. Errors: `ValueError` → 400, `NotFound` → 404, `TooLarge` → 413, `InvalidDag` → 422, `Busy` → 503, @@ -454,8 +457,11 @@ Everything in the specification is implemented, with these simplifications and a - Withdrawals are sent once, with any provides due for the same node. One that fails isn't retried, since the record expires after `PROVIDER_TTL`. A CID is withdrawn from the nodes of its last round and from nodes of earlier rounds, or of a round cut short, that may still hold its record (`Publisher._earlier`, persisted in - `published.json`, forgotten after `PROVIDER_TTL`). A round stopped before every node was sent its keys - leaves its CIDs due, and persisted times later than now are clamped to now. + `published.json`, forgotten after `PROVIDER_TTL`). Each node that accepts a key is added there as its + `/provide` returns, and `published.json` is saved every 10 nodes or 30 s of a round + (`SAVE_EVERY_NODES`, `SAVE_EVERY_SECONDS`), so a crash loses at most that much of where a round stored. + A round stopped before every node was sent its keys leaves its CIDs due, and persisted times later than now + are clamped to now. - A CID whose lookup found no nodes to publish to is retried after 30 s rather than at the next republish. - When every provider has failed, the provider lookup runs again only if its previous run found a new provider (and time is left). diff --git a/rnfs/publisher.py b/rnfs/publisher.py index 832b94b..f0cc6d9 100644 --- a/rnfs/publisher.py +++ b/rnfs/publisher.py @@ -47,6 +47,10 @@ # PROVIDER_TTL (7 days), so republishing every 3 days still survives a missed round KEY_MIN_INTERVAL = 3 * DAY KEY_JITTER = 0.1 # +-10% of the interval, fixed per key +# While a round sends, where it stored is saved after this many nodes or seconds, whichever comes first, so a +# crash loses at most that much of it (a record no saved state lists can't be withdrawn: it lives PROVIDER_TTL) +SAVE_EVERY_NODES = 10 +SAVE_EVERY_SECONDS = 30.0 # Slow nodes: a closest-nodes lookup is several /find exchanges (alpha 1, a few hops), seconds of airtime at # LoRa rates. One per 30 s keeps republishing a hundred keys to well under 1% of a day's airtime per hour SLOW_LOOKUP_PACE = 30.0 @@ -262,13 +266,35 @@ def refresh(self): record = self._published.pop(cid, None) for node_id in set(record[1] if record else []) | set(self._earlier.pop(cid, {})): self._withdrawals.setdefault(node_id, set()).add(cid.key) + cancelled = [cid for cid in added if self._provide_again(cid)] if removed: log(f"no longer providing {plural(len(removed), 'CID')}", LOG_VERBOSE) - if removed: + if removed or cancelled: self.save() if removed or any(self._is_due(cid, self.clock()) for cid in added): self.wake() + def _provide_again(self, cid: "Cid | DhtKey") -> bool: + """cid is provided (again): record ourselves locally and cancel withdrawals of its key still queued; + True if any were. Lock held. + + A withdrawal queued when it stopped being provided would otherwise reach a node after a round stored it + there again, deleting the fresh record (section 6). Those nodes may still hold a record of it, so they + join its earlier nodes, to be withdrawn from should it stop being provided again. + """ + self.providers.store(cid.key, self.own_id) # refresh() withdrew it when it stopped being provided + now = self.clock() + cancelled = False + for node_id, keys in list(self._withdrawals.items()): + if cid.key in keys: + keys.discard(cid.key) + if not keys: + del self._withdrawals[node_id] + earlier = self._earlier.setdefault(cid, {}) + earlier[node_id] = max(now, earlier.get(node_id, now)) + cancelled = True + return cancelled + # Publishing def wake(self): @@ -345,39 +371,61 @@ def publish_due(self, should_stop: Callable[[], bool] = lambda: False) -> dict: del closest[cid] self.providers.withdraw(cid.key, self.own_id) - by_node: dict[bytes, list[Cid]] = {} - for cid, nodes in closest.items(): - for node_id in nodes: - by_node.setdefault(node_id, []).append(cid) - superseded: dict[bytes, set[bytes]] = {} - for node_id, cids in by_node.items(): - # Withdrawing a key from a node while storing it there would delete the fresh record (a restart - # can queue withdrawals of keys still provided): the store wins, and the withdrawal is dropped - if node_id in withdrawals: - superseded[node_id] = withdrawals[node_id] & {cid.key for cid in cids} - withdrawals[node_id] -= superseded[node_id] + by_node: dict[bytes, list[Cid]] = {} + for cid, nodes in closest.items(): + for node_id in nodes: + by_node.setdefault(node_id, []).append(cid) + for node_id, cids in by_node.items(): + # Withdrawing a key from a node while storing it there would delete the fresh record: the store + # wins, and the withdrawal is dropped (refresh() cancels those of a key provided again, so this + # is only for state from elsewhere) + superseded = withdrawals.get(node_id, set()) & {cid.key for cid in cids} + if superseded: + withdrawals[node_id] -= superseded + self._withdrawals[node_id] -= superseded + if not self._withdrawals[node_id]: + del self._withdrawals[node_id] accepted: dict[Cid, list[bytes]] = {cid: [] for cid in closest} sent_withdrawals: dict[bytes, set[bytes]] = {} + unsaved, saved_at = 0, self.clock() for node_id in sorted(set(by_node) | set(withdrawals)): if should_stop(): break cids = by_node.get(node_id, []) - withdrawn = withdrawals.get(node_id, set()) + with self._lock: + # Only what is still queued: a key provided again since the snapshot had its withdrawals cancelled + withdrawn = withdrawals.get(node_id, set()) & self._withdrawals.get(node_id, set()) + if not cids and not withdrawn: + continue # Not retried until the next republish whatever the outcome, so it counts as sent either way. # The node may hold records of the keys it got before any refusal: those are withdrawn later recorded = self.client.provide(node_id, [cid.key for cid in cids], sorted(withdrawn)) sent_withdrawals[node_id] = withdrawn - for cid in cids: - if cid.key in recorded: + answered = self.clock() + with self._lock: + for cid in cids: + if cid.key not in recorded: + continue accepted[cid].append(node_id) + # Remembered at once, and saved every few nodes, so a round cut short by a crash still + # leaves a record of where it stored, to withdraw from (the round's end settles the rest) + if cid in self._provided: + earlier = self._earlier.setdefault(cid, {}) + earlier[node_id] = max(answered, earlier.get(node_id, answered)) + else: + self._withdrawals.setdefault(node_id, set()).add(cid.key) missed = sum(1 for cid in cids if cid.key not in recorded) if missed: log(f"{node_name(node_id)} didn't record {plural(missed, 'key')}", LOG_VERBOSE) + unsaved += 1 + if unsaved >= SAVE_EVERY_NODES or answered - saved_at >= SAVE_EVERY_SECONDS: + self.save() + unsaved, saved_at = 0, answered unfinished: list[Cid] = [] with self._lock: for node_id, keys in sent_withdrawals.items(): - remaining = self._withdrawals.get(node_id, set()) - keys - superseded.get(node_id, set()) + remaining = self._withdrawals.get(node_id, set()) - keys if remaining: self._withdrawals[node_id] = remaining else: @@ -403,7 +451,8 @@ def publish_due(self, should_stop: Callable[[], bool] = lambda: False) -> dict: earlier.pop(node_id, None) else: # Stopped before every node was sent its keys: left due (unpublished), keeping where it went - earlier.update((node_id, now) for node_id in accepted[cid]) + for node_id in accepted[cid]: + earlier[node_id] = max(now, earlier.get(node_id, now)) unfinished.append(cid) if earlier: self._earlier[cid] = earlier diff --git a/specification.md b/specification.md index 1de7246..206d3ab 100644 --- a/specification.md +++ b/specification.md @@ -475,12 +475,14 @@ and repeat. a request it refused (e.g. up to a local limit), so the publisher SHOULD count every key it sent to that node, up to and including that request, as possibly recorded there. - To stop providing a CID (unpinned, deleted), a node SHOULD send it as a withdrawn key to the nodes it last - published it to, and to nodes of earlier rounds (or of a round cut short) whose record may not have expired: - the closest nodes change as nodes come and go. + published it to, and to nodes of earlier rounds (or of a round cut short, including by a crash) whose record + may not have expired: the closest nodes change as nodes come and go. So it SHOULD persist where a round + stored as the round goes, not only when it ends (rnfs: every 10 nodes or 30 s). - A responder applies a `/provide`'s withdrawals before its keys, so a key in both lists is stored. A publisher MUST NOT send a withdrawal of a key in a later request to the same node than a store of it (that - would delete what it just stored): it sends withdrawals in the first batches, and doesn't withdraw a key it - is providing to that node in the same round. + would delete what it just stored): it sends withdrawals in the first batches, doesn't withdraw a key it + is providing to that node in the same round, and cancels the withdrawals still queued for a key when it + provides that key again (it may stop and start providing it while a round is sending). - A slow node SHOULD pace its closest-nodes lookups, since each is several `/find` exchanges at radio rates (rnfs: at most 10 per publish round, and after a round of n lookups the next waits 30 × n s; items never published go first, then those unpublished longest). Withdrawals are not delayed by this. diff --git a/tests/security/test_new1_publisher_withdraw_race.py b/tests/security/test_new1_publisher_withdraw_race.py new file mode 100644 index 0000000..f7cb6ce --- /dev/null +++ b/tests/security/test_new1_publisher_withdraw_race.py @@ -0,0 +1,176 @@ +"""NEW-1: a publish round could withdraw a CID from the nodes that had just stored it. + +publish_due snapshots the pending withdrawals and then sends without the lock. A refresh() during the sends that +stops providing a CID queues withdrawals of its key; if the CID is provided again before the round ends, the round +records it as freshly published and the next round sends those withdrawals to the nodes holding the fresh records +(breaking section 6's MUST), and the node's own local record is gone too. These tests force the interleavings +through the fake client: each node holds the records its /provide requests leave (withdrawals, then stores), and +after the rounds settle every node the publisher thinks holds the CID must hold it, and so must the node itself. +""" +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.content import builder +from rnfs.content.storage import USER, Storage +from rnfs.dht.providers import ProviderStore +from rnfs.publisher import Publisher +from tests.support import FakeClock +from tests.test_publisher import FakeClient, FakeLookup + +OWN = bytes(16) +NODES = [bytes([i]) * 16 for i in (1, 2, 3)] + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the publisher's log.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class HoldingClient(FakeClient): + """A FakeClient whose nodes keep the records its requests leave, and which runs a hook before a request.""" + + def __init__(self): + """No records held, no hook.""" + super().__init__() + self.held: dict[bytes, set[bytes]] = {} + self.before = lambda index: None # called with the request's index in self.calls, before it is applied + + def provide(self, node_id, keys, withdrawn): + """Run the hook, then apply the request as a responder does: withdrawals first, then stores.""" + self.before(len(self.calls)) + recorded = super().provide(node_id, keys, withdrawn) + held = self.held.setdefault(node_id, set()) + held -= set(withdrawn) + held |= recorded + return recorded + + +class WithdrawRaceTest(unittest.TestCase): + """Unpinning and re-pinning a CID while a republish round sends its /provide requests.""" + + def setUp(self): + """A publisher over real storage and a provider store; a pinned CID published once to NODES.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + state = os.path.join(self.tmp.name, "state") + config = Config(rnfs_dir=self.tmp.name, republish_interval=1000) + self.storage = Storage(os.path.join(self.tmp.name, "blocks"), state, 10**9) + self.records = ProviderStore(1000, None) + self.client = HoldingClient() + self.clock = FakeClock(1_000_000.0) + self.publisher = Publisher(OWN, config, self.storage, self.records, self.client, + lambda key: FakeLookup(NODES), False, state, clock=self.clock) + self.cid = builder.build_bytes(b"some content", self.storage.put).cid + self.storage.pin(self.cid, USER) + self.publisher.refresh() + self.publisher.publish_due() # round 1: stored on every node + self.clock.now += 1001 # republish due + + def unpin(self): + """Stop providing the CID, as `pin rm` does.""" + self.storage.unpin(self.cid, USER) + self.publisher.refresh() + + def repin(self): + """Provide the CID again, as `pin add` does.""" + self.storage.pin(self.cid, USER) + self.publisher.refresh() + + def settle(self): + """Run rounds until nothing is due, as the publish loop does (at most a few).""" + for _ in range(5): + if self.publisher.next_due() > 0: + return + self.publisher.publish_due() + self.fail("the publisher never settled") + + def problems(self) -> list[str]: + """What is wrong once settled with the CID provided: records the publisher counts on that are gone.""" + key = self.cid.key + found = [] + published = self.publisher._published.get(self.cid) + for node_id in (published[1] if published else []): + if key not in self.client.held.get(node_id, set()): + found.append(f"{node_id.hex()[:4]} was sent a withdrawal after storing it") + if OWN not in self.records.providers(key): + found.append("the node's own record is gone") + return found + + def test_unpin_and_repin_during_the_first_send(self): + """The reviewer's interleaving: both happen just before the round's first /provide.""" + def before(index): + if index == len(NODES): # the first request of round 2 + self.unpin() + self.repin() + self.client.before = before + self.publisher.publish_due() # round 2 + self.client.before = lambda index: None + self.settle() + self.assertIn(self.cid, self.publisher.provided()) + # NEW-1: fails until refresh() cancels the pending withdrawals of a key it provides again and re-records + # the node's own record + self.assertEqual(self.problems(), []) + + def test_every_interleaving_of_unpin_and_repin_with_the_sends(self): + """Unpin before request i and re-pin before request j >= i of round 2, or after it ends (j == end).""" + first = len(NODES) # requests of round 1 + failures = [] + for i in range(len(NODES)): + for j in range(i, len(NODES) + 1): + with self.subTest(unpin=i, repin=j): + self.setUp() + + def before(index, i=i, j=j): + if index == first + i: + self.unpin() + if index == first + j: + self.repin() + self.client.before = before + self.publisher.publish_due() # round 2 + self.client.before = lambda index: None + if j == len(NODES): + self.repin() # after round 2 ended + self.settle() + self.assertIn(self.cid, self.publisher.provided()) + found = self.problems() + if found: + failures.append(f"unpin before send {i}, repin before {j}: {found}") + # NEW-1: fails until the invariant holds for every interleaving + self.assertEqual(failures, []) + + def test_no_withdrawal_follows_a_store_to_the_same_node(self): + """Section 6's MUST, read off the requests: once settled with the CID provided, no node got a withdrawal + of its key in a later request than a store of it.""" + def before(index): + if index == len(NODES) + 1: # round 2's second request: the first node already has the fresh record + self.unpin() + self.repin() + self.client.before = before + self.publisher.publish_due() + self.client.before = lambda index: None + self.settle() + key = self.cid.key + late = [] + for node_id in NODES: + stored_at = [n for n, (target, keys, _) in enumerate(self.client.calls) if target == node_id and key in keys] + withdrawn_at = [n for n, (target, keys, withdrawn) in enumerate(self.client.calls) + if target == node_id and key in withdrawn and key not in keys] + if withdrawn_at and stored_at and max(withdrawn_at) > max(stored_at): + late.append(node_id.hex()[:4]) + # NEW-1: fails until a withdrawal queued before the CID was provided again is cancelled + self.assertEqual(late, [], "withdrawn after the last store while still provided") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_new6_publisher_crash_cut_round.py b/tests/security/test_new6_publisher_crash_cut_round.py new file mode 100644 index 0000000..75938ef --- /dev/null +++ b/tests/security/test_new6_publisher_crash_cut_round.py @@ -0,0 +1,115 @@ +"""NEW-6 (publisher half): records stored during a publish round cut short by a crash were never withdrawn. + +Where a round stored a CID was only written to published.json at the round's end, so after a crash mid-round +nothing listed those nodes, and when the CID later stopped being provided they kept the record for up to +PROVIDER_TTL (section 6 SHOULD: withdraw from nodes of a round cut short). These tests crash a round from the fake +client after some nodes accepted, restart the publisher on the same state directory, stop providing the CID, and +check that every node saved as accepted (every SAVE_EVERY_NODES nodes or SAVE_EVERY_SECONDS) gets a withdrawal. +""" +import os +import tempfile +import unittest + +import RNS + +from rnfs import publisher as publisher_module +from rnfs.config import Config +from rnfs.content import builder +from rnfs.content.storage import USER, Storage +from rnfs.dht.providers import ProviderStore +from rnfs.publisher import Publisher +from tests.support import FakeClock +from tests.test_publisher import FakeClient, FakeLookup + +OWN = bytes(16) +# Read this way so the tests also run (and fail for the stated reason) on code from before the fix +SAVE_EVERY_NODES = getattr(publisher_module, "SAVE_EVERY_NODES", 10) +SAVE_EVERY_SECONDS = getattr(publisher_module, "SAVE_EVERY_SECONDS", 30.0) + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the publisher's log.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class Crash(BaseException): + """Stands in for the process dying in the middle of a round.""" + + +class CrashingClient(FakeClient): + """A FakeClient that dies before request number crash_at, advancing the clock by step per request.""" + + def __init__(self, clock: FakeClock, crash_at: int, step: float = 0.0): + """Crash before the crash_at'th request (0-based).""" + super().__init__() + self.clock = clock + self.crash_at = crash_at + self.step = step + + def provide(self, node_id, keys, withdrawn): + """Record the request, or crash.""" + if len(self.calls) == self.crash_at: + raise Crash() + self.clock.now += self.step + return super().provide(node_id, keys, withdrawn) + + +class CrashCutRoundTest(unittest.TestCase): + """A round over many nodes, crashed part way, then a restart that stops providing the CID.""" + + def setUp(self): + """Storage with one pinned CID; a temporary state directory.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.state = os.path.join(self.tmp.name, "state") + self.config = Config(rnfs_dir=self.tmp.name, republish_interval=1000) + self.storage = Storage(os.path.join(self.tmp.name, "blocks"), self.state, 10**9) + self.clock = FakeClock(1_000_000.0) + self.cid = builder.build_bytes(b"some content", self.storage.put).cid + self.storage.pin(self.cid, USER) + + def publisher(self, client, nodes) -> Publisher: + """A publisher on the shared state directory whose lookups find nodes.""" + return Publisher(OWN, self.config, self.storage, ProviderStore(1000, None), client, + lambda key: FakeLookup(nodes), False, self.state, clock=self.clock) + + def crash_then_unpin(self, nodes, crash_at: int, step: float = 0.0) -> set[bytes]: + """Crash the first round before request crash_at; restart, unpin, publish; the nodes withdrawn from.""" + first = self.publisher(CrashingClient(self.clock, crash_at, step), nodes) + first.refresh() + with self.assertRaises(Crash): + first.publish_due() + # The crashed publisher is abandoned: only what it saved survives + client = FakeClient() + again = self.publisher(client, nodes) + again.refresh() + self.storage.unpin(self.cid, USER) + again.refresh() + again.publish_due() + return {node_id for node_id, _, withdrawn in client.calls if self.cid.key in withdrawn} + + def test_nodes_saved_every_few_nodes_are_withdrawn_from_after_a_crash(self): + """Crash after SAVE_EVERY_NODES + 2 nodes accepted: at least the first SAVE_EVERY_NODES are withdrawn from.""" + nodes = [bytes([i]) * 16 for i in range(1, SAVE_EVERY_NODES + 6)] + withdrawn = self.crash_then_unpin(nodes, crash_at=SAVE_EVERY_NODES + 2) + # NEW-6: fails until accepted nodes are recorded (and saved) as each /provide returns + self.assertLessEqual(set(nodes[:SAVE_EVERY_NODES]), withdrawn) + self.assertLessEqual(withdrawn, set(nodes[:SAVE_EVERY_NODES + 2])) # never a node it didn't store on + + def test_nodes_saved_after_some_seconds_are_withdrawn_from_after_a_crash(self): + """A slow round (each /provide takes over SAVE_EVERY_SECONDS) crashed after two nodes: both are kept.""" + nodes = [bytes([i]) * 16 for i in range(1, 5)] + withdrawn = self.crash_then_unpin(nodes, crash_at=2, step=SAVE_EVERY_SECONDS + 1) + # NEW-6: fails until accepted nodes are saved at least every SAVE_EVERY_SECONDS during a round + self.assertEqual(withdrawn, set(nodes[:2])) + + +if __name__ == "__main__": + unittest.main() From b77bf1acb89a15e25bc52ce64489096c405e8b8e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:33:22 +1030 Subject: [PATCH 041/107] Check directories again when walking by path, and each file's final path, so a swapped-in junction can't lead outside the folder --- CHANGELOG.md | 8 ++ README.md | 5 + design.md | 6 +- rnfs/content/builder.py | 115 ++++++++++++++++-- rnfs/content/shared_folder.py | 7 +- rnfs/fsutil.py | 62 ++++++++++ tests/security/test_l8_path_walk_checks.py | 133 +++++++++++++++++++++ 7 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 tests/security/test_l8_path_walk_checks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ecc2..1e016e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ Fixes: - Names given as text must be exactly 32 hex digits (optionally in ``, with spaces around): whitespace between the digits used to be skipped, giving a name shorter than 16 bytes. +Platform and packaging: +- Where directories are walked by path (Windows), adding a directory or scanning the shared folder checks + each directory again whenever it lists it (same file ID, not a junction, symlink or other name-surrogate + reparse point, real path still inside the folder) and each file's final path, from its open handle, before + reading it. A directory swapped for a junction during a scan used to import files from outside the shared + folder and share them; now what changed is skipped with a warning. The README says the shared folder must + not be writable by accounts you don't trust. Not yet tested on Windows itself. + Gateway privacy: - Errors from the node other than not-found (`400`, `413`, `422`, `503`, `507`, as well as `500`) get a fixed message for their status; the detail goes to the daemon's log. A `507` used to show the local blocks diff --git a/README.md b/README.md index 1fb19d0..49eb874 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,11 @@ Uninstalling never deletes it. | `files/` | the shared folder: anything put here is added, pinned and shared automatically | | `api_token` | the running daemon's API token | +The shared folder must not be writable by accounts you don't trust. rnfs skips links inside it and checks +every directory and file it opens there, but on Windows, where directories are walked by path, someone who +can write to it gets a race: swapping a directory for a junction while it is scanned. The checks are made to +win that race, but don't rely on them: keep `files/` (and anything you link it to) changeable only by you. + ## Sample files Some files & Folders that I host as examples for everyone to download. Fetch them with a running daemon that is diff --git a/design.md b/design.md index 99d40db..4eb0e54 100644 --- a/design.md +++ b/design.md @@ -522,7 +522,11 @@ Everything in the specification is implemented, with these simplifications and a - On Windows (`rnfs/fsutil.py`, `IS_WINDOWS`): there is no execute bit, so `add` records every file as kind 0 and `get` sets none; Unix permission checks are skipped (the rnfs dir relies on the ACL it inherits from the user's profile). Directories are walked by path rather than descriptor, each entry's type and file ID checked - before use, and directory junctions are skipped like symlinks. Replacing or deleting a file another handle + before use, and directory junctions (and other name-surrogate reparse points) are skipped like symlinks. + Each directory is checked again whenever it is listed (`builder.list_directory`: same file ID, not a link, + real path inside the walk's root), and each file opened below it must have its final path + (`fsutil.final_path`, GetFinalPathNameByHandleW) inside that root, so a directory swapped for a junction + mid-walk can't lead outside it (audit L8). Replacing or deleting a file another handle has open is retried for about a second; a block still in use then stays stored and indexed and is deleted by a later collection. `get` skips names Windows can't represent (`:`, device names, trailing dots), and the API and gateway bind their ports exclusively. The daemon stops cleanly on Ctrl+C or Ctrl+Break (SIGBREAK). diff --git a/rnfs/content/builder.py b/rnfs/content/builder.py index 0afff0c..2a347af 100644 --- a/rnfs/content/builder.py +++ b/rnfs/content/builder.py @@ -212,24 +212,107 @@ def is_executable(mode: int) -> bool: # Opening paths. A directory being added may change underneath the walk (the shared folder is writable by # whatever puts files there), so every entry is opened relative to its parent's descriptor without following # a symlink or blocking on a FIFO, and the opened object must be the regular file or directory that was listed. +# Where directories can't be opened (FD_WALK is False, as on Windows) they are walked by path, and a directory +# checked once could be swapped for a link or junction afterwards: so each one is checked again, by stat and by +# real path, whenever it is listed, and each file opened below it must really be below the walk's root (its +# final path, from the open file) before a byte is read (audit L8). _O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) _O_NONBLOCK = getattr(os, "O_NONBLOCK", 0) # opening a FIFO for reading would otherwise wait for a writer _O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) _O_CLOEXEC = getattr(os, "O_CLOEXEC", 0) _REPARSE_JUNCTION = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003) +# Reparse tags with this bit are name surrogates: they stand for another file or directory (symlinks, +# junctions, WSL symlinks), unlike placeholders such as OneDrive's that hold the file itself +_REPARSE_NAME_SURROGATE = 0x20000000 # Platforms without these (not Linux or macOS) walk directories by path, checking each entry before using it FD_WALK = (os.open in os.supports_dir_fd and os.scandir in os.supports_fd and hasattr(os, "O_NOFOLLOW") and hasattr(os, "O_DIRECTORY")) # What open(O_NOFOLLOW | O_NONBLOCK [| O_DIRECTORY]) reports for a symlink, socket or non-directory _SWAPPED_ERRNOS = {errno.ELOOP, errno.EMLINK, errno.ENOTDIR, errno.ENXIO} -Handle = int | str # an open file's descriptor; an open directory's descriptor, or its path without FD_WALK +Handle = int | str # an open file's descriptor; an open directory's descriptor, or a _DirPath without FD_WALK class Changed(OSError): """An entry changed type or identity between being listed and being opened.""" +class _DirPath(str): + """A directory "opened" without FD_WALK: its path, with what is needed to check it again before each use. + + expected is the stat it was listed (or first opened) with, follow whether its path may end in a link (only + the root the user named), and root the real path of the walk's root, which nothing below may leave. + """ + expected: os.stat_result + follow: bool + root: str + + +def _dir_path(path: str, expected: os.stat_result, follow: bool, root: str | None) -> _DirPath: + """A _DirPath for path; root None makes it the root of a walk, whose real path must still be it.""" + handle = _DirPath(path) + handle.expected = expected + handle.follow = follow + if root is None: + root = os.path.realpath(path) + st = os.stat(root) + if (st.st_dev, st.st_ino) != (expected.st_dev, expected.st_ino): + raise Changed(errno.ESTALE, "changed while being read", display_path(path)) + handle.root = root + return handle + + +def _check_dir_path(handle: _DirPath): + """Raise Changed unless the path of handle still leads, without a link or junction on the way below its + root, to the directory it was opened as.""" + changed = Changed(errno.ESTALE, "changed while being read", display_path(handle)) + st = os.stat(handle, follow_symlinks=handle.follow) + if (is_link(st) or not stat.S_ISDIR(st.st_mode) + or (st.st_dev, st.st_ino) != (handle.expected.st_dev, handle.expected.st_ino)): + raise changed + # A parent directory swapped for a link would still pass the stat above, if it leads to the same directory + # by another way; the real path shows where it is + real = os.path.realpath(handle) + if not fsutil.is_below(real, handle.root): + raise changed + st = os.stat(real) + if (st.st_dev, st.st_ino) != (handle.expected.st_dev, handle.expected.st_ino): + raise changed + + +def _check_below(fd: int, root: str, path: str): + """Raise Changed unless the file open as fd (opened by path, below a _DirPath) is really below root. + + Its final path comes from the open file where the system can say (fsutil.final_path): a directory on the + way, swapped for a link or junction after its check, can't hide where the open led. Elsewhere the path is + resolved again, and must lead to this very file below root. + """ + changed = Changed(errno.ESTALE, "changed while being read", display_path(path)) + final = fsutil.final_path(fd) + if final is None: + final = os.path.realpath(path) + st, opened = os.stat(final), os.fstat(fd) + if (st.st_dev, st.st_ino) != (opened.st_dev, opened.st_ino): + raise changed + if not fsutil.is_below(final, root): + raise changed + + +def list_directory(handle: Handle) -> list[os.DirEntry]: + """The entries of the open directory handle, sorted by name. + + A _DirPath is checked before and after it is listed, so a listing of another directory swapped in + meanwhile is never used. Raises Changed then, OSError if it can't be listed. + """ + if isinstance(handle, _DirPath): + _check_dir_path(handle) + with os.scandir(handle) as listing: + entries = sorted(listing, key=lambda entry: entry.name) + if isinstance(handle, _DirPath): + _check_dir_path(handle) + return entries + + def _check_opened(path: str, expected: os.stat_result, handle: Handle, follow: bool = False): """Raise Changed unless handle is the regular file or directory that was listed as expected. @@ -242,11 +325,15 @@ def _check_opened(path: str, expected: os.stat_result, handle: Handle, follow: b raise Changed(errno.ESTALE, "changed while being read", display_path(path)) -def _open(target: str, dir_fd: int | None, path: str, expected: os.stat_result, follow: bool) -> Handle: - """Open target (a name in dir_fd, or a path) as the regular file or directory expected, never blocking.""" +def _open(target: str, dir_fd: int | None, path: str, expected: os.stat_result, follow: bool, + root: str | None = None) -> Handle: + """Open target (a name in dir_fd, or a path) as the regular file or directory expected, never blocking. + + root is the real path of the walk's root when target is a path below a _DirPath, else None. + """ is_dir = stat.S_ISDIR(expected.st_mode) if is_dir and not FD_WALK: - handle: Handle = path # can't open directories here; checked by path instead + handle: Handle = _dir_path(path, expected, follow, root) # can't open directories here: checked by path else: flags = os.O_RDONLY | fsutil.O_BINARY | _O_NONBLOCK | _O_CLOEXEC | (_O_DIRECTORY if is_dir else 0) if not follow: @@ -259,6 +346,10 @@ def _open(target: str, dir_fd: int | None, path: str, expected: os.stat_result, raise try: _check_opened(path, expected, handle, follow) + if isinstance(handle, _DirPath): + _check_dir_path(handle) + elif root is not None: + _check_below(handle, root, path) except BaseException: close(handle) raise @@ -284,7 +375,8 @@ def open_child(parent: Handle, name: str, path: str, expected: os.stat_result) - """ if isinstance(parent, int): return _open(name, parent, path, expected, False) - return _open(path, None, path, expected, False) + root = getattr(parent, "root", None) or os.path.realpath(parent) + return _open(path, None, path, expected, False, root) def listed_stat(item: os.DirEntry, path: str) -> os.stat_result: @@ -300,8 +392,10 @@ def listed_stat(item: os.DirEntry, path: str) -> os.stat_result: def is_link(st: os.stat_result) -> bool: - """True for a symlink, or a Windows directory junction (which stat reports as a directory, yet it links).""" - return stat.S_ISLNK(st.st_mode) or getattr(st, "st_reparse_tag", 0) == _REPARSE_JUNCTION + """True for a symlink, a Windows directory junction (which stat reports as a directory, yet it links) or any + other Windows reparse point that stands for another file (a name surrogate).""" + tag = getattr(st, "st_reparse_tag", 0) + return stat.S_ISLNK(st.st_mode) or tag == _REPARSE_JUNCTION or bool(tag & _REPARSE_NAME_SURROGATE) def close(handle: Handle): @@ -345,8 +439,7 @@ def _build_tree(directory: Handle, path: str, put: Put, warnings: list[str], dep raise TooLarge(f"{display_path(path)} is nested deeper than MAX_DEPTH ({constants.MAX_DEPTH})") entries = [] child_height = 0 - with os.scandir(directory) as listing: - children = sorted(listing, key=lambda entry: entry.name) + children = list_directory(directory) for item in children: child_path = os.path.join(path, item.name) shown = display_path(child_path) @@ -367,6 +460,10 @@ def _build_tree(directory: Handle, path: str, put: Put, warnings: list[str], dep continue try: child = build_open(handle, st, child_path, put, warnings, depth + 1) + except Changed: + # A directory walked by path, swapped when it was listed (see list_directory) + warnings.append(f"skipped {shown}: it changed while being added") + continue finally: close(handle) entries.append(Entry(item.name, child.cid, child.size, child.kind)) diff --git a/rnfs/content/shared_folder.py b/rnfs/content/shared_folder.py index 58960ba..ac5f6e7 100644 --- a/rnfs/content/shared_folder.py +++ b/rnfs/content/shared_folder.py @@ -50,9 +50,7 @@ def add(item: builder.Handle | None, item_path: str, relative: str, st: os.stat_ # Deeper than MAX_DEPTH can't be added anyway; don't recurse without bound if item is None or not stat.S_ISDIR(mode) or depth > constants.MAX_DEPTH + 1: return - with os.scandir(item) as listing: - children = sorted(listing, key=lambda entry: entry.name) - for child in children: + for child in builder.list_directory(item): child_path = os.path.join(item_path, child.name) child_st = builder.listed_stat(child, child_path) # A Windows junction stats as a directory, but add skips it like a symlink: don't walk into it @@ -116,8 +114,7 @@ def scan(self) -> set[Cid]: try: if not stat.S_ISDIR(st.st_mode): raise NotADirectoryError(f"{display_path(self.directory)} is not a directory") - with os.scandir(folder) as listing: - items = sorted(listing, key=lambda entry: entry.name) + items = builder.list_directory(folder) for item in items: result = self._scan_item(folder, item) if result is not None: diff --git a/rnfs/fsutil.py b/rnfs/fsutil.py index ffd4fe4..502e75a 100644 --- a/rnfs/fsutil.py +++ b/rnfs/fsutil.py @@ -124,6 +124,68 @@ def sync_directory(directory: str): os.close(fd) +def final_path(fd: int) -> str | None: + """The path the system holds for the file open as fd, every link and junction resolved; None where the + platform can't say. Raises OSError if it can but fails. + + What was opened, not where its path leads now: a directory swapped for a link after the open can't change + it. Linux reads /proc/self/fd, macOS asks fcntl(F_GETPATH) and Windows GetFinalPathNameByHandleW (by the + real platform, not IS_WINDOWS, which tests patch). + """ + if os.name == "nt": + return _windows_final_path(fd) + if sys.platform.startswith("linux"): + try: + path = os.readlink(f"/proc/self/fd/{fd}") + except FileNotFoundError: + return None # no /proc mounted + return path if path.startswith("/") else None # e.g. "anon_inode:[...]": not a file at a path + if sys.platform == "darwin": + import fcntl + if hasattr(fcntl, "F_GETPATH"): + return os.fsdecode(fcntl.fcntl(fd, fcntl.F_GETPATH, bytes(1024)).split(b"\0", 1)[0]) + return None + + +def strip_windows_prefix(path: str) -> str: + r"""A path from GetFinalPathNameByHandleW in the form os.path.realpath gives: \\?\C:\x as C:\x and + \\?\UNC\server\share\x as \\server\share\x.""" + if path.startswith("\\\\?\\UNC\\"): + return "\\\\" + path[8:] + if path.startswith("\\\\?\\"): + return path[4:] + return path + + +def _windows_final_path(fd: int) -> str: + """final_path on Windows: GetFinalPathNameByHandleW on the descriptor's handle, in drive-letter form.""" + import ctypes + import msvcrt + from ctypes import wintypes + function = ctypes.WinDLL("kernel32", use_last_error=True).GetFinalPathNameByHandleW + function.argtypes = (wintypes.HANDLE, wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD) + function.restype = wintypes.DWORD + handle = msvcrt.get_osfhandle(fd) + size = 1024 + while True: + buffer = ctypes.create_unicode_buffer(size) + length = function(handle, buffer, size, 0) # 0: FILE_NAME_NORMALIZED | VOLUME_NAME_DOS + if length == 0: + raise ctypes.WinError(ctypes.get_last_error()) + if length < size: + return strip_windows_prefix(buffer.value) + size = length + 1 # the buffer was too small, and length is what it needs + + +def is_below(path: str, root: str) -> bool: + """True if path is root or inside it; both should be real (resolved) paths. Case-insensitive on Windows.""" + path, root = os.path.normcase(path), os.path.normcase(root) + try: + return os.path.commonpath([root, path]) == root + except ValueError: + return False # on different drives, or one relative + + def _temp_path(directory: str) -> str: """A new, unpredictable name for a temporary file in directory.""" return os.path.join(directory, PARTIAL_PREFIX + secrets.token_hex(8)) diff --git a/tests/security/test_l8_path_walk_checks.py b/tests/security/test_l8_path_walk_checks.py new file mode 100644 index 0000000..58c8b72 --- /dev/null +++ b/tests/security/test_l8_path_walk_checks.py @@ -0,0 +1,133 @@ +"""L8 (fix): where directories are walked by path (FD_WALK False, as on Windows), each directory is checked again +whenever it is listed and each file opened below it must really be below the walk's root, so a directory swapped for +a link or junction can't lead the walk outside. + +Simulated on any POSIX system with FD_WALK forced to False and a symlink standing in for the junction. Each of the two +checks is tested with the other one disabled, so either alone keeps the outside file out; the Windows-only parts +(reparse tags, GetFinalPathNameByHandleW's prefixes) are tested on their pure logic. Not run on Windows. +""" +import os +import tempfile +import unittest +from unittest import mock + +from rnfs import fsutil +from rnfs.content import builder +from rnfs.content.builder import NO_PUT +from rnfs.content.cid import RAW, Cid +from tests.support import CAN_SYMLINK + +SECRET = b"TOP SECRET: outside the folder being added" + + +@unittest.skipUnless(CAN_SYMLINK, "creating symlinks isn't allowed here") +class PathWalkTest(unittest.TestCase): + """files/sub/harmless.txt being added, and private/secret.txt outside it.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.private = os.path.join(self.tmp.name, "private") + os.mkdir(self.private) + with open(os.path.join(self.private, "secret.txt"), "wb") as f: + f.write(SECRET) + self.files = os.path.join(self.tmp.name, "files") + self.sub = os.path.join(self.files, "sub") + os.makedirs(self.sub) + with open(os.path.join(self.sub, "harmless.txt"), "wb") as f: + f.write(b"hi") + fd_walk = mock.patch.object(builder, "FD_WALK", False) + fd_walk.start() + self.addCleanup(fd_walk.stop) + + def swap_sub_when_listed(self): + """A patch that swaps sub for a symlink to private just as the walk lists sub (after it opened it).""" + real = builder.list_directory + + def list_then_swap(handle): + """Swap sub on its first listing, then list it as builder does.""" + if handle == self.sub and not os.path.islink(self.sub): + os.rename(self.sub, self.sub + ".moved") + os.symlink(self.private, self.sub) + return real(handle) + + return mock.patch.object(builder, "list_directory", list_then_swap) + + def add(self) -> tuple[set[Cid], list[str]]: + """Add files, storing every block; the CIDs stored and the warnings.""" + stored = set() + _, warnings = builder.add_path(self.files, lambda cid, block: stored.add(cid)) + return stored, warnings + + def assert_secret_not_read(self, stored: set[Cid], warnings: list[str]): + """Nothing from private was stored, and the walk said it skipped what changed.""" + self.assertNotIn(Cid.for_block(RAW, SECRET), stored, "the secret was read from outside the folder") + self.assertTrue([w for w in warnings if "changed while being added" in w], warnings) + + def test_swapped_directory_is_skipped(self): + with self.swap_sub_when_listed(): + self.assert_secret_not_read(*self.add()) + + def test_directory_check_alone_keeps_it_out(self): + # Without the final-path check of each file, the directory's own check refuses the swapped listing + with self.swap_sub_when_listed(), mock.patch.object(builder, "_check_below"): + self.assert_secret_not_read(*self.add()) + + def test_final_path_check_alone_keeps_it_out(self): + # Without the directory checks (as if the swap fell between a check and the listing), the file opened + # through the link has its final path outside the folder + with self.swap_sub_when_listed(), mock.patch.object(builder, "_check_dir_path"): + self.assert_secret_not_read(*self.add()) + + def test_final_path_check_without_system_support(self): + # Where the system can't name an open file's path, the path resolved again must lead to it, below the root + with self.swap_sub_when_listed(), mock.patch.object(builder, "_check_dir_path"), \ + mock.patch.object(fsutil, "final_path", return_value=None): + self.assert_secret_not_read(*self.add()) + + def test_unchanged_tree_still_adds(self): + # The checks don't refuse an honest tree, nor one named through a symlink to it (the user's choice) + expected = builder.add_path(self.files, NO_PUT)[0] + link = os.path.join(self.tmp.name, "link") + os.symlink(self.files, link, target_is_directory=True) + self.assertEqual(builder.add_path(link, NO_PUT), (expected, [])) + + def test_final_path_names_the_open_file(self): + path = os.path.join(self.sub, "harmless.txt") + fd = os.open(path, os.O_RDONLY) + self.addCleanup(os.close, fd) + final = fsutil.final_path(fd) + if final is None: + self.skipTest("this system can't name an open file's path") + self.assertEqual(final, os.path.realpath(path)) + + +class WindowsPartsTest(unittest.TestCase): + """The platform-independent logic of the Windows side.""" + + def test_name_surrogate_reparse_points_are_links(self): + st = os.stat(tempfile.gettempdir()) + for tag, links in ((0xA0000003, True), # junction (mount point) + (0xA000000C, True), # symlink + (0xA000001D, True), # WSL symlink + (0x9000001A, False), # OneDrive (cloud files) placeholder: the directory itself + (0, False)): + with self.subTest(hex(tag)): + self.assertEqual(builder.is_link(mock.Mock(st_mode=st.st_mode, st_reparse_tag=tag)), links) + + def test_strip_windows_prefix(self): + self.assertEqual(fsutil.strip_windows_prefix("\\\\?\\C:\\Users\\a\\x"), "C:\\Users\\a\\x") + self.assertEqual(fsutil.strip_windows_prefix("\\\\?\\UNC\\srv\\share\\x"), "\\\\srv\\share\\x") + self.assertEqual(fsutil.strip_windows_prefix("C:\\x"), "C:\\x") + + def test_is_below(self): + root = os.path.join(tempfile.gettempdir(), "root") + self.assertTrue(fsutil.is_below(root, root)) + self.assertTrue(fsutil.is_below(os.path.join(root, "a", "b"), root)) + self.assertFalse(fsutil.is_below(root + "2", root)) # a sibling sharing the prefix + self.assertFalse(fsutil.is_below(os.path.dirname(root), root)) + self.assertFalse(fsutil.is_below("relative", root)) + + +if __name__ == "__main__": + unittest.main() From b18fb023b08b85908a5c6a6df86831a6d231f38a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:33:22 +1030 Subject: [PATCH 042/107] Expect the path walk to stay in the shared folder in the L8 swap test --- tests/security/test_l8_folder_swap.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/tests/security/test_l8_folder_swap.py b/tests/security/test_l8_folder_swap.py index bb84a49..22370f2 100644 --- a/tests/security/test_l8_folder_swap.py +++ b/tests/security/test_l8_folder_swap.py @@ -3,9 +3,10 @@ Simulated on any POSIX system: builder.FD_WALK is forced to False and a symlink stands in for the junction. The swap happens at a fixed point of the scan (after the signature walk of a top-level entry, or right after a nested -directory passed its check), so there is no timing race. With the path walk the outside file is imported and pinned -"folder" (so the node provides it); with the descriptor walk (FD_WALK True, the real setting on Linux and macOS) -it never is. +directory passed its check), so there is no timing race. Before the fix, the path walk imported the outside file and +pinned it "folder" (so the node provided it); now the path walk checks each directory again whenever it lists it, and +each file's final path, so neither walk (nor the descriptor walk, FD_WALK True, the real setting on Linux and macOS) +ever reads it. """ import os import tempfile @@ -16,7 +17,7 @@ from rnfs.content import builder, dag, shared_folder, walker from rnfs.content.cid import RAW, Cid from rnfs.content.shared_folder import SharedFolder -from rnfs.content.storage import FOLDER, Storage +from rnfs.content.storage import Storage from tests.support import CAN_SYMLINK SECRET = b"TOP SECRET: outside the shared folder" @@ -111,24 +112,11 @@ def outside_files(self, pins: set[Cid]) -> set[str]: """Imported files that came from the private directory.""" return {path for path in self.imported_files(pins) if path.endswith("secret.txt")} - def assert_leaked(self, pins: set[Cid]): - """The secret was read into storage and is in the folder's pinned (so provided) DAG.""" - self.assertTrue(self.storage.has(Cid.for_block(RAW, SECRET))) - self.assertTrue(self.outside_files(pins)) - self.assertEqual(pins, self.storage.pinned_by(FOLDER)) - def assert_not_leaked(self, pins: set[Cid]): """Nothing from outside the folder was read into storage.""" self.assertFalse(self.storage.has(Cid.for_block(RAW, SECRET)), "the secret's block was stored") self.assertEqual(self.outside_files(pins), set(), "imported from outside the folder") - def test_path_walk_imports_outside_files(self): - for swapping in (self.swap_after_signature, self.swap_after_check): - with self.subTest(swapping.__name__): - self.make_folder() - pins = self.scan(False, swapping) - self.assert_leaked(pins) - @unittest.skipUnless(builder.FD_WALK, "directories opened by descriptor (POSIX)") def test_descriptor_walk_stays_in_the_folder(self): for swapping in (self.swap_after_signature, self.swap_after_check): @@ -138,9 +126,6 @@ def test_descriptor_walk_stays_in_the_folder(self): self.assertTrue(os.path.islink(self.drop) or os.path.islink(os.path.join(self.drop, "sub"))) self.assert_not_leaked(pins) - # L8: fails until the path walk re-checks each directory's identity (or walks by handle) so a swapped-in - # junction can't lead outside the folder - @unittest.expectedFailure def test_path_walk_stays_in_the_folder(self): for swapping in (self.swap_after_signature, self.swap_after_check): self.make_folder() From 1d0b0e67d8e78b3e1865e83cd78403dc8fa90997 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:38:43 +1030 Subject: [PATCH 043/107] Give the rnfs dir, API token and key files a private access list on Windows, and check it --- CHANGELOG.md | 6 + README.md | 5 + design.md | 11 +- rnfs/cli.py | 16 +- rnfs/fsutil.py | 12 +- rnfs/names.py | 2 +- rnfs/network/identity.py | 25 +- rnfs/node.py | 10 +- rnfs/winacl.py | 359 ++++++++++++++++++++++++++ specification.md | 7 +- tests/security/test_l7_windows_acl.py | 307 ++++++++++++++++++++++ 11 files changed, 736 insertions(+), 24 deletions(-) create mode 100644 rnfs/winacl.py create mode 100644 tests/security/test_l7_windows_acl.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e016e5..3b74e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ Platform and packaging: reading it. A directory swapped for a junction during a scan used to import files from outside the shared folder and share them; now what changed is skipped with a warning. The README says the shared folder must not be writable by accounts you don't trust. Not yet tested on Windows itself. +- On Windows the rnfs dir, the API token, the node and LXMF identities and name keys get a protected access + list allowing only the user and SYSTEM; they used to inherit their folder's, which with `--dir` outside the + user profile (another drive, `C:\ProgramData`) usually let every local user read the token and the keys. + An existing rnfs dir or identity file that lets others in is tightened (with a notice), the daemon refuses + an rnfs dir owned by another account, and every command refuses a token file others can read, as on POSIX. + A `--dir` outside `%USERPROFILE%` gets a warning. Not yet tested on Windows itself. Gateway privacy: - Errors from the node other than not-found (`400`, `413`, `422`, `503`, `507`, as well as `500`) get a fixed diff --git a/README.md b/README.md index 49eb874..dd746ce 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,11 @@ Uninstalling never deletes it. | `files/` | the shared folder: anything put here is added, pinned and shared automatically | | `api_token` | the running daemon's API token | +The rnfs directory holds your keys and the API token, so rnfs keeps it private: readable by you only, and on +Windows with an access list for you and SYSTEM only (also on another drive or under `C:\ProgramData`, where +Windows would otherwise let every local user read it). A `--dir` outside your user profile still gets a +warning on Windows, since accounts that can change its parent folder could replace it. + The shared folder must not be writable by accounts you don't trust. rnfs skips links inside it and checks every directory and file it opens there, but on Windows, where directories are walked by path, someone who can write to it gets a race: swapping a directory for a junction while it is scanned. The checks are made to diff --git a/design.md b/design.md index 4eb0e54..57987c2 100644 --- a/design.md +++ b/design.md @@ -252,7 +252,12 @@ an optional `from` (node ID); `GET /api/name/resolve?name=` takes a name or a na The API requires `X-Rnfs-Api: ` on every request except `GET /api/status`. The daemon writes a new random token to `/api_token` (mode 0600, in the 0700 rnfs dir) at each start and the CLI reads it, so other local users and processes can't drive the API. `_make_private_dirs` refuses to start (RnfsError) if the rnfs dir -belongs to another user or can't be made 0700 (not on Windows). A response to a request with +belongs to another user or can't be made 0700. On Windows (`rnfs/winacl.py`), mode bits set no ACL, so a new +rnfs dir gets a protected DACL allowing only the user and SYSTEM (inherited below it), an existing one that +allows anyone else is given it, and one owned by another account is refused; the token, identity and name key +files get the same DACL before they are renamed into place (`fsutil.write_atomic(private=True)`), and +`read_token` checks the opened token's DACL (Administrators tolerated). A `--dir` outside `%USERPROFILE%` is +warned about. The advapi32 calls are behind `winacl.backend`, which tests replace with a fake. A response to a request with `X-Rnfs-Challenge: <32 hex>` carries `X-Rnfs-Proof: cli.api_proof(token, port, challenge)` (HMAC-SHA256 of `rnfs api proof `); `ApiClient.verify_daemon` checks it with `GET /api/status` before first sending the token. Both HTTP servers (`TimedHandler`) time out slow requests, cap concurrent connections (API @@ -520,8 +525,8 @@ Everything in the specification is implemented, with these simplifications and a - `get` (`walker.write_out`) skips and reports names the destination filesystem refuses to create (EINVAL, ENAMETOOLONG, EILSEQ: e.g. `?` on FAT, long names on eCryptfs) rather than failing the whole write-out. - On Windows (`rnfs/fsutil.py`, `IS_WINDOWS`): there is no execute bit, so `add` records every file as kind 0 - and `get` sets none; Unix permission checks are skipped (the rnfs dir relies on the ACL it inherits from the - user's profile). Directories are walked by path rather than descriptor, each entry's type and file ID checked + and `get` sets none; Unix permission checks are replaced by access-list ones (`winacl`, see the API + section). Directories are walked by path rather than descriptor, each entry's type and file ID checked before use, and directory junctions (and other name-surrogate reparse points) are skipped like symlinks. Each directory is checked again whenever it is listed (`builder.list_directory`: same file ID, not a link, real path inside the walk's root), and each file opened below it must have its final path diff --git a/rnfs/cli.py b/rnfs/cli.py index 4bab375..170fcb9 100644 --- a/rnfs/cli.py +++ b/rnfs/cli.py @@ -21,7 +21,7 @@ import sys from urllib.parse import urlencode -from rnfs import __version__, fsutil +from rnfs import __version__, fsutil, winacl from rnfs.config import DEFAULT_API_PORT, Config DEFAULT_DIR = Config.rnfs_dir @@ -52,11 +52,12 @@ def token_path(rnfs_dir: str) -> str: def write_token(path: str, token: str): - """Write the API token to path atomically, created with mode 0600 so other users can't read it.""" + """Write the API token to path atomically, created with mode 0600 so other users can't read it (on Windows, + with an access list for this user and SYSTEM only).""" # A new file (O_EXCL, O_NOFOLLOW) in the same directory, then a rename: the token is never readable with # wider permissions, nor half-written, and an existing file or symlink at path is replaced, not written # through. Ended with os.linesep, as the text-mode write before 0.1.2 did, so the file is unchanged - fsutil.write_atomic(path, (token + os.linesep).encode("ascii"), mode=0o600) + fsutil.write_atomic(path, (token + os.linesep).encode("ascii"), mode=0o600, private=True) def remove_token(path: str, token: str): @@ -88,10 +89,17 @@ def read_token(path: str) -> str: fail(f"can't read the API token at {path}: {e}") with os.fdopen(fd, "rb") as f: mode = stat.S_IMODE(os.fstat(f.fileno()).st_mode) - # Windows has no Unix permission bits to check + # Windows has no Unix permission bits to check, but an access list (of the very file opened) if not fsutil.IS_WINDOWS and mode & 0o077: fail(f"{path} is accessible to other users (mode {mode:04o}), so its token can't be trusted; " f"restart the daemon to make a new one") + try: + reason = winacl.check_private(path, f.fileno()) + except OSError as e: + fail(f"can't check who may read the API token at {path}: {e}") + if reason is not None: + fail(f"{path} is accessible to other users ({reason}), so its token can't be trusted; " + f"restart the daemon to make a new one") token = f.read(1024).decode("ascii", "replace").strip() if not token: fail(f"the API token file {path} is empty; restart the daemon") diff --git a/rnfs/fsutil.py b/rnfs/fsutil.py index 502e75a..b829156 100644 --- a/rnfs/fsutil.py +++ b/rnfs/fsutil.py @@ -191,7 +191,8 @@ def _temp_path(directory: str) -> str: return os.path.join(directory, PARTIAL_PREFIX + secrets.token_hex(8)) -def write_atomic(path: str, data: bytes, *, mode: int = 0o644, durable: bool = True, exclusive: bool = False): +def write_atomic(path: str, data: bytes, *, mode: int = 0o644, durable: bool = True, exclusive: bool = False, + private: bool = False): """Replace path with data, so a crash or power loss leaves the old or the new file whole. Raises OSError. data goes to a new temporary file in path's directory (O_EXCL, plus O_NOFOLLOW where it exists, i.e. not on @@ -199,8 +200,10 @@ def write_atomic(path: str, data: bytes, *, mode: int = 0o644, durable: bool = T disk and renamed over path (retried on Windows while path is in use); a symlink at path is replaced, not written through. If durable, the directory is then synced so the rename survives a power loss too. With exclusive, an existing path is kept - and FileExistsError raised instead. On any failure before the rename the temporary file is removed and path - is left as it was. + and FileExistsError raised instead. With private (for keys and the API token, written with mode 0600), on + Windows, where mode sets no access list, the file also gets a protected DACL for this user and SYSTEM only + (winacl.make_private) before it takes path's place. On any failure before the rename the temporary file is + removed and path is left as it was. """ directory = os.path.dirname(path) or "." temp = _temp_path(directory) @@ -211,6 +214,9 @@ def write_atomic(path: str, data: bytes, *, mode: int = 0o644, durable: bool = T f.write(data) f.flush() os.fsync(f.fileno()) # else a power loss can leave path empty once renamed + if private and IS_WINDOWS: + from rnfs import winacl + winacl.make_private(temp) # by path, once closed: the handle written through can't change its DACL if exclusive: rename_new(temp, path) else: diff --git a/rnfs/names.py b/rnfs/names.py index 1cf28ab..06bad54 100644 --- a/rnfs/names.py +++ b/rnfs/names.py @@ -746,7 +746,7 @@ def _add_key(self, alias: str, identity: RNS.Identity, published: _Published): os.makedirs(self.config.names_dir, mode=0o700, exist_ok=True) # Written atomically and private: RNS's Identity.to_file does neither. The temp name starts with "." # (fsutil.PARTIAL_PREFIX) so it can never be an alias - fsutil.write_atomic(self._key_path(alias), identity.get_private_key(), mode=0o600) + fsutil.write_atomic(self._key_path(alias), identity.get_private_key(), mode=0o600, private=True) name = name_for_identity(identity) self._keys[alias] = identity self._names[alias] = name diff --git a/rnfs/network/identity.py b/rnfs/network/identity.py index 6ecfe1e..0b014d0 100644 --- a/rnfs/network/identity.py +++ b/rnfs/network/identity.py @@ -4,7 +4,7 @@ import RNS -from rnfs import fsutil +from rnfs import fsutil, winacl from rnfs.constants import APP_NAME, DHT_ASPECT, ID_LENGTH from rnfs.log import LOG_WARNING, log @@ -38,12 +38,13 @@ def save_identity(identity: RNS.Identity, path: str, replace: bool = True) -> bo world-readable) and could leave a truncated key behind if interrupted. """ # The file is created with O_CREAT | O_EXCL and mode 0600 (IDENTITY_MODE), which a umask can only narrow, - # so no chmod is needed (and os.fchmod doesn't exist on Windows before Python 3.13). On Windows the file - # inherits the rnfs dir's ACL instead: under the user's profile, only them, admins and SYSTEM. Without + # so no chmod is needed (and os.fchmod doesn't exist on Windows before Python 3.13). On Windows, where the + # mode sets no access list, it gets a protected one for this user and SYSTEM only (private). Without # replace, rename_new keeps an existing file atomically where the platform allows (hard link, # RENAME_NOREPLACE) try: - fsutil.write_atomic(path, identity.get_private_key(), mode=IDENTITY_MODE, exclusive=not replace) + fsutil.write_atomic(path, identity.get_private_key(), mode=IDENTITY_MODE, exclusive=not replace, + private=True) except FileExistsError: if replace: raise @@ -52,9 +53,21 @@ def save_identity(identity: RNS.Identity, path: str, replace: bool = True) -> bo def _restrict_permissions(path: str): - """Make an existing identity file owner-only if group or others could access it (e.g. an older rnfs's).""" + """Make an existing identity file owner-only if group or others could access it (e.g. an older rnfs's). + + On Windows, which has no Unix permission bits (st_mode always shows group and others allowed), its access + list is checked and tightened instead (winacl). + """ if fsutil.IS_WINDOWS: - return # no Unix permission bits: st_mode always shows group and others allowed, so this would always fire + try: + reason = winacl.tighten(path) + except OSError as e: + log(f"could not restrict access to identity file {path}: {e}", LOG_WARNING) + return + if reason is not None: + log(f"identity file {path} was accessible to other users ({reason}); " + f"restricted it to you and SYSTEM", LOG_WARNING) + return try: mode = stat.S_IMODE(os.stat(path).st_mode) if mode & 0o077: diff --git a/rnfs/node.py b/rnfs/node.py index c998b61..fd9333f 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -23,7 +23,7 @@ import RNS -from rnfs import __version__, features, fsutil +from rnfs import __version__, features, fsutil, winacl from rnfs.config import Config from rnfs.constants import APP_NAME, DEFAULT_BOOTSTRAP_NODES, DHT_ASPECT, MODE_SERVER, PARTIAL_SUFFIX from rnfs.content import archive, builder, dag, edit, walker @@ -65,7 +65,8 @@ def _make_private_dirs(config: Config): - """Create the rnfs dir and the node's subdirectories readable by the owner only (0700). + """Create the rnfs dir and the node's subdirectories readable by the owner only (0700; on Windows, a + protected access list for the user and SYSTEM, see winacl). They hold the node's identity, name keys and the local API token, so other local users must not read them; a root left group- or world-accessible by earlier versions is tightened. Raises RnfsError if the @@ -73,9 +74,10 @@ def _make_private_dirs(config: Config): config there. """ root = config.root + existed = os.path.isdir(root) os.makedirs(root, mode=0o700, exist_ok=True) - # Windows has no Unix permission bits (st_mode always shows others allowed), so only the ACL that the - # rnfs dir inherits from the user's profile protects it there + # Windows has no Unix permission bits (st_mode always shows others allowed): an explicit access list there + winacl.secure_rnfs_dir(root, created=not existed) if not fsutil.IS_WINDOWS: info = os.stat(root) if info.st_uid != os.geteuid(): diff --git a/rnfs/winacl.py b/rnfs/winacl.py new file mode 100644 index 0000000..d5ba45b --- /dev/null +++ b/rnfs/winacl.py @@ -0,0 +1,359 @@ +"""Owner-only access on Windows: an explicit, protected DACL for the rnfs dir and its secret files (audit L7). + +POSIX systems get this from mode bits (a 0700 rnfs dir, 0600 key and token files, checked by +node._make_private_dirs and cli.read_token), and there everything here does nothing. Windows ignores mode bits, +so a new file just inherits its directory's access list: under the user's profile that is the user, +Administrators and SYSTEM, but elsewhere (another drive, C:\\ProgramData) it usually lets every local user read +the API token, the node's identity and the name keys. So on Windows: + +- the daemon gives a new rnfs dir a protected DACL (nothing inherited from its parent) that grants full control + to the current user and SYSTEM only, inherited by everything created below it, and gives an existing one + that lets anyone else in the same DACL (which Windows passes on to what is below it); +- the API token, identity and name key files get that DACL explicitly when written (fsutil.write_atomic's + private), and an existing identity file that lets others in is tightened as on POSIX; +- every CLI command checks the token file's DACL before trusting it, and the daemon the rnfs dir's at start. + +A DACL counts as private if it allows nobody but the current user, SYSTEM and Administrators (who can read any +file anyway); deny entries and audit entries don't matter. The owner must be one of those too, since an owner can +always change the DACL. Reading or setting a DACL that fails is an error, never a pass. + +The Windows calls (advapi32 through ctypes) sit behind a small backend so tests can swap in a fake: they patch +fsutil.IS_WINDOWS and `backend`. The ACL and SID formats are parsed and built here in Python, so that part is +tested on any platform. No RNS dependency, so the CLI can use it. +""" +import os +import struct +from typing import NamedTuple + +from rnfs import fsutil + +SYSTEM = "S-1-5-18" +ADMINISTRATORS = "S-1-5-32-544" +CREATOR_OWNER = "S-1-3-0" # only a placeholder in inheritable entries: it never matches an account +OWNER_RIGHTS = "S-1-3-4" # rights of whoever owns the object, which is checked separately +_NAMES = {"S-1-1-0": "Everyone", "S-1-5-11": "Authenticated Users", "S-1-5-32-545": "Users", + "S-1-5-32-546": "Guests", "S-1-5-7": "Anonymous", "S-1-5-4": "Interactive"} + +# ACE types (winnt.h). Allowed entries carry their SID right after the mask; deny, audit and label entries +# restrict or only log, so they can't let anyone in +ACCESS_ALLOWED = 0x0 +ACCESS_ALLOWED_CALLBACK = 0x9 +_ALLOW_TYPES = {ACCESS_ALLOWED, ACCESS_ALLOWED_CALLBACK} +_HARMLESS_TYPES = {0x1, 0x2, 0x3, 0x6, 0x7, 0x8, 0xA, 0xC, 0xD, 0xE, 0xF, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15} +OBJECT_INHERIT = 0x1 +CONTAINER_INHERIT = 0x2 +INHERIT_ONLY = 0x8 +FILE_ALL_ACCESS = 0x001F01FF +ACL_REVISION = 2 + + +class Ace(NamedTuple): + """One access control entry: its type, flags, access mask and SID (None if it carries none rnfs reads).""" + + type: int + flags: int + mask: int + sid: str | None + + +class Dacl(NamedTuple): + """An object's owner SID and its DACL's entries; entries None for a NULL DACL (which allows everyone).""" + + owner: str | None + entries: list[Ace] | None + + +# The SID and ACL formats (winnt.h): parsing what Windows returns, and building the private DACL + +def sid_to_str(data: bytes) -> str: + """The S-1-... form of a binary SID. Raises ValueError if data isn't one.""" + if len(data) < 8 or data[0] != 1: + raise ValueError("not a SID") + count = data[1] + if len(data) < 8 + 4 * count: + raise ValueError("truncated SID") + authority = int.from_bytes(data[2:8], "big") + subs = struct.unpack_from(f"<{count}I", data, 8) + return "S-1-" + "-".join(str(n) for n in (authority, *subs)) + + +def sid_length(data: bytes) -> int: + """Bytes in the binary SID at the start of data.""" + return 8 + 4 * data[1] + + +def sid_from_str(text: str) -> bytes: + """The binary form of an S-1-... SID string. Raises ValueError if it isn't one.""" + parts = text.split("-") + if len(parts) < 3 or parts[0] != "S" or parts[1] != "1" or len(parts) > 3 + 15: + raise ValueError(f"not a SID: {text}") + authority, subs = int(parts[2]), [int(p) for p in parts[3:]] + return bytes([1, len(subs)]) + authority.to_bytes(6, "big") + struct.pack(f"<{len(subs)}I", *subs) + + +def parse_acl(data: bytes) -> list[Ace]: + """The entries of a binary ACL. Raises ValueError if it is malformed.""" + if len(data) < 8: + raise ValueError("truncated ACL") + _, _, size, count, _ = struct.unpack_from(" len(data): + raise ValueError("truncated ACL") + kind, flags, ace_size, mask = struct.unpack_from(" len(data): + raise ValueError("bad ACE size") + sid = None + if kind in _ALLOW_TYPES or kind in (0x1, 0xA): # the plain allow and deny layouts: header, mask, SID + sid = sid_to_str(data[offset + 8:offset + ace_size]) + entries.append(Ace(kind, flags, mask, sid)) + offset += ace_size + return entries + + +def private_acl(user_sid: str, directory: bool) -> bytes: + """A binary ACL granting full control to user_sid and SYSTEM only, inherited below it if directory.""" + flags = OBJECT_INHERIT | CONTAINER_INHERIT if directory else 0 + entries = b"" + for sid in (user_sid, SYSTEM): + body = sid_from_str(sid) + entries += struct.pack(" str: + """A SID for messages, with the name of a well-known group.""" + return f"{_NAMES[sid]} ({sid})" if sid in _NAMES else sid + + +def owner_problem(dacl: Dacl, user_sid: str) -> str | None: + """Why dacl's owner could let others in (it isn't the user, SYSTEM or Administrators), or None.""" + if dacl.owner is None: + return "it has no owner" + if dacl.owner not in (user_sid, SYSTEM, ADMINISTRATORS): + return f"it belongs to {_name(dacl.owner)}" + return None + + +def access_problem(dacl: Dacl, user_sid: str) -> str | None: + """Why dacl lets someone other than the user, SYSTEM and Administrators in, or None if it doesn't. + + Inherit-only entries count too: on a directory they would let others into what is created in it. + """ + if dacl.entries is None: + return "it has no access list, so everyone may access it" + trusted = {user_sid, SYSTEM, ADMINISTRATORS, CREATOR_OWNER, OWNER_RIGHTS} + for entry in dacl.entries: + if entry.type in _HARMLESS_TYPES or entry.mask == 0: + continue + if entry.type not in _ALLOW_TYPES: + return f"it has an access entry rnfs can't check (type {entry.type})" + if entry.sid not in trusted: + return f"{_name(entry.sid)} may access it" + return None + + +def problem(dacl: Dacl, user_sid: str) -> str | None: + """Why the object with dacl isn't private to user_sid, or None if it is.""" + return owner_problem(dacl, user_sid) or access_problem(dacl, user_sid) + + +# The Windows calls + +class _Windows: + """advapi32 through ctypes: the current user's SID, an object's owner and DACL, and setting the private DACL.""" + + SE_FILE_OBJECT = 1 + OWNER_SECURITY_INFORMATION = 0x1 + DACL_SECURITY_INFORMATION = 0x4 + PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000 + TOKEN_QUERY = 0x8 + TOKEN_USER = 1 + + def __init__(self): + import ctypes + from ctypes import wintypes + self.ctypes = ctypes + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + p = ctypes.c_void_p + pp = ctypes.POINTER(ctypes.c_void_p) + self.get_named = advapi32.GetNamedSecurityInfoW + self.get_named.argtypes = (wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD, pp, pp, pp, pp, pp) + self.get_named.restype = wintypes.DWORD + self.get_handle = advapi32.GetSecurityInfo + self.get_handle.argtypes = (wintypes.HANDLE, ctypes.c_int, wintypes.DWORD, pp, pp, pp, pp, pp) + self.get_handle.restype = wintypes.DWORD + self.set_named = advapi32.SetNamedSecurityInfoW + self.set_named.argtypes = (wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD, p, p, p, p) + self.set_named.restype = wintypes.DWORD + self.open_token = advapi32.OpenProcessToken + self.open_token.argtypes = (wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)) + self.open_token.restype = wintypes.BOOL + self.token_info = advapi32.GetTokenInformation + self.token_info.argtypes = (wintypes.HANDLE, ctypes.c_int, p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)) + self.token_info.restype = wintypes.BOOL + self.current_process = kernel32.GetCurrentProcess + self.current_process.restype = wintypes.HANDLE + self.close_handle = kernel32.CloseHandle + self.close_handle.argtypes = (wintypes.HANDLE,) + self.local_free = kernel32.LocalFree + self.local_free.argtypes = (p,) + self.local_free.restype = p + self._user_sid: str | None = None + + def _error(self, code: int, path: str | None = None) -> OSError: + """An OSError for a Win32 error code.""" + error = self.ctypes.WinError(code) + if path is not None: + error.filename = path + return error + + def _sid_at(self, address: int) -> str: + """The S-1-... form of the binary SID at address.""" + head = self.ctypes.string_at(address, 8) + return sid_to_str(self.ctypes.string_at(address, sid_length(head))) + + def user_sid(self) -> str: + """The SID of the user this process runs as (its token's user, not its owner default).""" + if self._user_sid is None: + from ctypes import wintypes + ctypes = self.ctypes + token = wintypes.HANDLE() + if not self.open_token(self.current_process(), self.TOKEN_QUERY, ctypes.byref(token)): + raise self._error(ctypes.get_last_error()) + try: + needed = wintypes.DWORD() + self.token_info(token, self.TOKEN_USER, None, 0, ctypes.byref(needed)) + buffer = ctypes.create_string_buffer(needed.value) + if not self.token_info(token, self.TOKEN_USER, buffer, needed, ctypes.byref(needed)): + raise self._error(ctypes.get_last_error()) + finally: + self.close_handle(token) + # TOKEN_USER starts with SID_AND_ATTRIBUTES, which starts with a pointer to the SID + self._user_sid = self._sid_at(ctypes.c_void_p.from_buffer(buffer).value) + return self._user_sid + + def read(self, path: str, fd: int | None = None) -> Dacl: + """The owner and DACL of the open file fd (if given, so the very file read is checked) or of path.""" + ctypes = self.ctypes + owner, dacl, descriptor = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p() + info = self.OWNER_SECURITY_INFORMATION | self.DACL_SECURITY_INFORMATION + refs = (ctypes.byref(owner), None, ctypes.byref(dacl), None, ctypes.byref(descriptor)) + if fd is not None: + import msvcrt + code = self.get_handle(msvcrt.get_osfhandle(fd), self.SE_FILE_OBJECT, info, *refs) + else: + code = self.get_named(path, self.SE_FILE_OBJECT, info, *refs) + if code != 0: + raise self._error(code, path) + try: + owner_sid = self._sid_at(owner.value) if owner.value else None + if not dacl.value: + return Dacl(owner_sid, None) + size = struct.unpack(" str | None: + """On Windows, why the file open as fd (or at path) isn't private to the current user, or None if it is (and + always None elsewhere). Raises OSError if its DACL can't be read.""" + acl = _backend() if fsutil.IS_WINDOWS else None + if acl is None: + return None + return problem(acl.read(path, fd), acl.user_sid()) + + +def tighten(path: str, directory: bool = False) -> str | None: + """On Windows, make an existing path private if its DACL lets others in; what was wrong (None if nothing). + + Raises PermissionError if it belongs to someone else (its owner could open it again), OSError if it can't + be read or set. + """ + acl = _backend() if fsutil.IS_WINDOWS else None + if acl is None: + return None + user = acl.user_sid() + dacl = acl.read(path) + reason = owner_problem(dacl, user) + if reason is not None: + raise PermissionError(f"{path}: {reason}") + reason = access_problem(dacl, user) + if reason is None: + return None + acl.set_private(path, directory) + after = problem(acl.read(path), user) + if after is not None: + raise PermissionError(f"{path} is still not private after setting its access list: {after}") + return reason + + +def outside_profile(path: str) -> bool: + """On Windows, True if path is outside the user's profile (%USERPROFILE%), whose ACL keeps other users out.""" + if not fsutil.IS_WINDOWS: + return False + profile = os.environ.get("USERPROFILE") + if not profile: + return False + return not fsutil.is_below(os.path.realpath(path), os.path.realpath(profile)) + + +def secure_rnfs_dir(root: str, created: bool): + """On Windows, the rnfs dir's part of node._make_private_dirs: a protected owner-only DACL on a new root, + an existing one tightened if others may access it, and a warning for a root outside the user's profile. + + Raises RnfsError if root belongs to another account or can't be made private. + """ + if not fsutil.IS_WINDOWS: + return + from rnfs.errors import RnfsError + from rnfs.log import LOG_NOTICE, LOG_WARNING, log + try: + if created: + make_private(root, directory=True) + reason = check_private(root) + if reason is not None: + raise PermissionError(f"{root} is still not private after setting its access list: {reason}") + else: + reason = tighten(root, directory=True) + if reason is not None: + log(f"made {root} private to you and SYSTEM ({reason})", LOG_NOTICE) + except OSError as e: + raise RnfsError(f"couldn't make the rnfs dir {root} private to you: {e}") from None + if outside_profile(root): + log(f"the rnfs dir {root} is outside your user profile. rnfs has made it private to you, but accounts " + f"that can change its parent folder could replace it: keep it in a folder only you can change", + LOG_WARNING) diff --git a/specification.md b/specification.md index 1de7246..0bc5daa 100644 --- a/specification.md +++ b/specification.md @@ -921,13 +921,14 @@ gateway can never reach the API. DNS rebinding. - Every API request except `GET /api/status` MUST carry `X-Rnfs-Api: `, compared in constant time. The token is random (at least 128 bits), made anew at each daemon start and written to - `/api_token` with mode 0600; the CLI reads it from there and refuses a file other users can - access. Every local process, of every user, can connect to `127.0.0.1`, and the API reads and writes + `/api_token` with mode 0600 (on Windows, with a protected access list allowing only the user and + SYSTEM); the CLI reads it from there and refuses a file other users can access. Every local process, of every user, can connect to `127.0.0.1`, and the API reads and writes files as the daemon's user, so only users who can read the rnfs directory may use it. Web pages can't learn the token, and the custom header needs a CORS preflight, which fails: this also blocks cross-site requests. Status stays open for health checks. - The daemon MUST refuse to start if the rnfs directory belongs to another user or can't be made accessible - to its owner only (outside Windows, where it relies on the ACL inherited from the user's profile). + to its owner only (on Windows: a protected access list allowing only the user and SYSTEM, which the files + below it inherit; Administrators are tolerated where an older version left them). - An API request carrying `X-Rnfs-Challenge: <32 hex digits>` is answered with `X-Rnfs-Proof: hex(HMAC-SHA256(token, "rnfs api proof "))`, `` being the API's. Before it first sends the token, the CLI checks this proof with a `GET /api/status`, so a program another diff --git a/tests/security/test_l7_windows_acl.py b/tests/security/test_l7_windows_acl.py new file mode 100644 index 0000000..4009a77 --- /dev/null +++ b/tests/security/test_l7_windows_acl.py @@ -0,0 +1,307 @@ +"""L7 (fix): on Windows the API token and rnfs dir relied on inherited ACLs, so with --dir outside the user's profile +other local users could read api_token, the identity key and the name keys. Now the rnfs dir, the token and the key +files get a protected DACL for the user and SYSTEM only, and the CLI and the daemon check it. + +Simulated with fakes on any platform: fsutil.IS_WINDOWS is patched and winacl.backend is a FakeAcl that keeps a DACL +per file (by device and inode, so it follows a file across the atomic write's rename) and reports a chosen +"inherited" DACL for files it never set, standing in for a folder that lets every local user read. The binary SID and +ACL formats the real backend reads and writes are tested on their own. The real backend (advapi32 through ctypes) is +not exercised: none of this has been run on Windows. +""" +import os +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs import cli, fsutil, node as node_module, winacl +from rnfs.config import Config +from rnfs.errors import RnfsError +from rnfs.network import identity as identity_module +from rnfs.winacl import ADMINISTRATORS, SYSTEM, Ace, Dacl +from tests.test_names import NamesTestCase + +USER = "S-1-5-21-1111-2222-3333-1001" +OTHER = "S-1-5-21-1111-2222-3333-1002" +USERS = "S-1-5-32-545" +EVERYONE = "S-1-1-0" +FULL = winacl.FILE_ALL_ACCESS +READ = 0x120089 # FILE_GENERIC_READ +INHERITED = 0x10 # INHERITED_ACE + + +def allow(sid: str, mask: int = FULL, flags: int = INHERITED) -> Ace: + """An inherited allow entry.""" + return Ace(winacl.ACCESS_ALLOWED, flags, mask, sid) + + +# What a file inherits under the user's profile (rnfs's default dir), and under a folder such as C:\ProgramData +# or another drive's root, where every local user may read +PROFILE = Dacl(USER, [allow(SYSTEM), allow(ADMINISTRATORS), allow(USER)]) +SHARED = Dacl(USER, [allow(SYSTEM), allow(ADMINISTRATORS), allow(USER), allow(USERS, READ)]) + + +class FakeAcl: + """A winacl backend: a DACL per file, set_private's calls, and `inherited` for files never set.""" + + def __init__(self, inherited: Dacl = SHARED): + self.inherited = inherited + self.dacls: dict[tuple[int, int], Dacl] = {} + self.set_calls: list[tuple[str, bool]] = [] + self.read_fds: list[int | None] = [] + + @staticmethod + def key(path: str, fd: int | None = None) -> tuple[int, int]: + """The file's identity, which a rename keeps.""" + st = os.fstat(fd) if fd is not None else os.stat(path) + return st.st_dev, st.st_ino + + def user_sid(self) -> str: + return USER + + def read(self, path: str, fd: int | None = None) -> Dacl: + self.read_fds.append(fd) + return self.dacls.get(self.key(path, fd), self.inherited) + + def set_private(self, path: str, directory: bool): + self.set_calls.append((path, directory)) + self.dacls[self.key(path)] = Dacl(USER, winacl.parse_acl(winacl.private_acl(USER, directory))) + + def set(self, path: str, dacl: Dacl): + """Give path dacl, as someone else (or an older rnfs) left it.""" + self.dacls[self.key(path)] = dacl + + +class WindowsTestCase(unittest.TestCase): + """A temporary directory, fsutil.IS_WINDOWS patched on and a FakeAcl as winacl's backend.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.acl = FakeAcl() + for patch in (mock.patch.object(fsutil, "IS_WINDOWS", True), mock.patch.object(winacl, "backend", self.acl)): + patch.start() + self.addCleanup(patch.stop) + + def private(self, path: str) -> bool: + """The fake says path is private to the user.""" + return winacl.problem(self.acl.read(path), USER) is None + + +class TokenTest(WindowsTestCase): + """cli.write_token and cli.read_token.""" + + def setUp(self): + super().setUp() + self.path = os.path.join(self.tmp.name, cli.TOKEN_FILE) + + def write_plain(self, token: str = "t" * 32): + """A token file written without an access list of its own, as rnfs 0.1.3 wrote it.""" + with open(self.path, "w") as f: + f.write(token + "\n") + + def assert_refused(self, message: str): + """read_token exits with message.""" + with self.assertRaises(SystemExit) as caught: + cli.read_token(self.path) + self.assertIn(message, str(caught.exception)) + + def test_written_with_a_private_dacl_and_accepted(self): + cli.write_token(self.path, "secret-token") + # Set on the temporary file, before it took the token's name: never readable by others meanwhile + [(temp, directory)] = self.acl.set_calls + self.assertTrue(os.path.basename(temp).startswith(fsutil.PARTIAL_PREFIX)) + self.assertEqual(os.path.dirname(temp), self.tmp.name) + self.assertFalse(directory) + self.assertTrue(self.private(self.path)) + self.assertEqual(cli.read_token(self.path), "secret-token") + self.assertIsNotNone(self.acl.read_fds[-1], "the DACL checked is the opened file's") + + def test_token_other_users_can_read_is_refused(self): + # The L7 case: a --dir outside the profile, its token inheriting a DACL that lets Users read + self.write_plain() + self.assert_refused("accessible to other users (Users (S-1-5-32-545) may access it)") + + def test_refused_for_any_other_account_null_dacl_or_owner(self): + for dacl, message in ((Dacl(USER, [allow(USER), allow(OTHER, READ)]), f"{OTHER} may access it"), + (Dacl(USER, [allow(USER), allow(EVERYONE, READ, 0x8)]), "Everyone"), # inherit-only + (Dacl(USER, [allow(USER), Ace(0x5, 0, READ, None)]), "can't check (type 5)"), + (Dacl(USER, None), "no access list, so everyone may access it"), + (Dacl(OTHER, [allow(USER)]), f"it belongs to {OTHER}")): + with self.subTest(message): + self.write_plain() + self.acl.set(self.path, dacl) + self.assert_refused(message) + + def test_dacl_that_cant_be_read_is_refused(self): + self.write_plain() + with mock.patch.object(self.acl, "read", side_effect=PermissionError(5, "Access is denied")): + self.assert_refused("can't check who may read the API token") + + def test_profile_default_is_accepted(self): + # A token an older rnfs wrote in the default dir: the user, SYSTEM and Administrators, plus entries that + # can't let anyone in (deny, audit, an allow with no rights) + self.write_plain("old-token") + self.acl.set(self.path, Dacl(ADMINISTRATORS, [*PROFILE.entries, Ace(0x1, 0, FULL, EVERYONE), + Ace(0x2, 0, FULL, None), allow(USERS, 0)])) + self.assertEqual(cli.read_token(self.path), "old-token") + + def test_failed_dacl_leaves_no_token(self): + cli.write_token(self.path, "old") + with mock.patch.object(self.acl, "set_private", side_effect=PermissionError(5, "Access is denied")): + with self.assertRaises(PermissionError): + cli.write_token(self.path, "new") + self.assertEqual(cli.read_token(self.path), "old") + self.assertEqual([n for n in os.listdir(self.tmp.name) if n.startswith(fsutil.PARTIAL_PREFIX)], []) + + +class RnfsDirTest(WindowsTestCase): + """node._make_private_dirs on Windows.""" + + def setUp(self): + super().setUp() + self.root = os.path.join(self.tmp.name, "rnfs") + log = mock.patch("rnfs.log.log") + self.log = log.start() + self.addCleanup(log.stop) + profile = mock.patch.dict(os.environ, {"USERPROFILE": self.tmp.name}) # the rnfs dir is inside it + profile.start() + self.addCleanup(profile.stop) + + def messages(self) -> str: + """Everything logged.""" + return "\n".join(call.args[0] for call in self.log.call_args_list) + + def test_new_dir_gets_a_protected_dacl(self): + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + self.assertEqual(self.acl.set_calls, [(self.root, True)]) + self.assertTrue(self.private(self.root)) + self.assertEqual(self.messages(), "") + + def test_existing_dir_others_can_read_is_tightened(self): + os.mkdir(self.root) + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + self.assertEqual(self.acl.set_calls, [(self.root, True)]) + self.assertTrue(self.private(self.root)) + self.assertIn("made " + self.root + " private to you and SYSTEM (Users (S-1-5-32-545) may access it)", + self.messages()) + + def test_existing_private_dir_is_left_alone(self): + # The default ~/.rnfs, inheriting the profile's DACL: nothing changed, nothing logged at every start + os.mkdir(self.root) + self.acl.set(self.root, PROFILE) + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + self.assertEqual(self.acl.set_calls, []) + self.assertEqual(self.messages(), "") + + def test_dir_of_another_account_is_refused(self): + os.mkdir(self.root) + self.acl.set(self.root, Dacl(OTHER, [allow(OTHER), allow(USER)])) + with self.assertRaisesRegex(RnfsError, f"couldn't make the rnfs dir .* private to you: .*belongs to {OTHER}"): + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + self.assertEqual(self.acl.set_calls, []) + self.assertFalse(os.path.exists(os.path.join(self.root, "state"))) + + def test_dir_that_cant_be_made_private_is_refused(self): + os.mkdir(self.root) + with mock.patch.object(self.acl, "set_private", side_effect=PermissionError(5, "Access is denied")): + with self.assertRaisesRegex(RnfsError, "couldn't make the rnfs dir"): + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + # Nor one whose DACL doesn't take (e.g. a filesystem without ACLs) + with mock.patch.object(self.acl, "set_private"): + with self.assertRaisesRegex(RnfsError, "still not private"): + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + + def test_dir_outside_the_profile_is_warned_about(self): + with mock.patch.dict(os.environ, {"USERPROFILE": os.path.join(self.tmp.name, "profile")}): + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + self.assertTrue(self.private(self.root)) + self.assertIn("is outside your user profile", self.messages()) + + +class KeyFileTest(WindowsTestCase): + """The identity file (load_or_create_identity) on Windows.""" + + def setUp(self): + super().setUp() + self.path = os.path.join(self.tmp.name, "storage", "identity") + + def test_new_identity_gets_a_private_dacl(self): + identity_module.load_or_create_identity(self.path) + self.assertTrue(self.private(self.path)) + [(temp, _)] = self.acl.set_calls + self.assertTrue(os.path.basename(temp).startswith(fsutil.PARTIAL_PREFIX)) + + def test_existing_identity_others_can_read_is_tightened(self): + os.makedirs(os.path.dirname(self.path)) + RNS.Identity().to_file(self.path) # as rnfs 0.1.3 left it outside the profile + with mock.patch.object(identity_module, "log") as log: + identity_module.load_or_create_identity(self.path) + self.assertTrue(self.private(self.path)) + self.assertIn("restricted it to you and SYSTEM", log.call_args.args[0]) + + def test_private_identity_is_left_alone(self): + identity_module.load_or_create_identity(self.path) + self.acl.set_calls.clear() + with mock.patch.object(identity_module, "log") as log: + identity_module.load_or_create_identity(self.path) + self.assertEqual(self.acl.set_calls, []) + log.assert_not_called() + + +class NameKeyTest(NamesTestCase): + """Name keys on Windows.""" + + def test_name_key_gets_a_private_dacl(self): + acl = FakeAcl() + with mock.patch.object(fsutil, "IS_WINDOWS", True), mock.patch.object(winacl, "backend", acl): + self.manager.create("site") + self.assertEqual(winacl.problem(acl.read(os.path.join(self.config.names_dir, "site")), USER), None) + + +class PosixUnchangedTest(unittest.TestCase): + """Outside Windows nothing here calls the backend: the mode-bit checks stay what they were.""" + + def test_no_backend_calls(self): + with tempfile.TemporaryDirectory() as directory, mock.patch.object(fsutil, "IS_WINDOWS", False), \ + mock.patch.object(winacl, "backend", mock.Mock(side_effect=AssertionError)) as backend: + node_module._make_private_dirs(Config(rnfs_dir=os.path.join(directory, "rnfs"))) + token = os.path.join(directory, "rnfs", cli.TOKEN_FILE) + cli.write_token(token, "t") + self.assertEqual(cli.read_token(token), "t") + identity_module.load_or_create_identity(os.path.join(directory, "rnfs", "storage", "identity")) + self.assertFalse(winacl.outside_profile("/")) + self.assertEqual(backend.mock_calls, []) + + +class FormatTest(unittest.TestCase): + """The binary SID and ACL formats the real backend reads and writes.""" + + def test_sid_round_trip(self): + for sid in (USER, SYSTEM, ADMINISTRATORS, EVERYONE): + with self.subTest(sid): + self.assertEqual(winacl.sid_to_str(winacl.sid_from_str(sid)), sid) + # S-1-5-18 as Windows stores it + self.assertEqual(winacl.sid_from_str(SYSTEM), bytes.fromhex("010100000000000512000000")) + for bad in ("S-1", "X-1-5", "S-2-5-18"): + with self.subTest(bad), self.assertRaises(ValueError): + winacl.sid_from_str(bad) + + def test_private_acl(self): + for directory, flags in ((False, 0), (True, winacl.OBJECT_INHERIT | winacl.CONTAINER_INHERIT)): + with self.subTest(directory=directory): + acl = winacl.private_acl(USER, directory) + self.assertEqual(int.from_bytes(acl[2:4], "little"), len(acl)) # AclSize + self.assertEqual(winacl.parse_acl(acl), [Ace(0, flags, FULL, USER), Ace(0, flags, FULL, SYSTEM)]) + self.assertIsNone(winacl.problem(Dacl(USER, winacl.parse_acl(acl)), USER)) + + def test_parse_acl_refuses_malformed(self): + acl = winacl.private_acl(USER, False) + for bad in (acl[:6], acl[:12], acl[:8] + b"\x00\x00\x04\x00" + acl[12:]): + with self.subTest(bad.hex()), self.assertRaises(ValueError): + winacl.parse_acl(bad) + + +if __name__ == "__main__": + unittest.main() From ea88d13b7fda888ed4d1dde1a185d2bd6dde9650 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:38:43 +1030 Subject: [PATCH 044/107] Don't follow a symlink when tightening the identity file's permissions --- CHANGELOG.md | 2 + rnfs/network/identity.py | 24 +++++--- tests/security/test_i2_identity_symlink.py | 65 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 tests/security/test_i2_identity_symlink.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b74e5d..3a68b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ Platform and packaging: An existing rnfs dir or identity file that lets others in is tightened (with a notice), the daemon refuses an rnfs dir owned by another account, and every command refuses a token file others can read, as on POSIX. A `--dir` outside `%USERPROFILE%` gets a warning. Not yet tested on Windows itself. +- The identity file's permission fix no longer follows a symlink: a symlink at the identity path is left as + it is, with a warning, and the file is changed through a descriptor opened with `O_NOFOLLOW`. Gateway privacy: - Errors from the node other than not-found (`400`, `413`, `422`, `503`, `507`, as well as `500`) get a fixed diff --git a/rnfs/network/identity.py b/rnfs/network/identity.py index 0b014d0..96ed03e 100644 --- a/rnfs/network/identity.py +++ b/rnfs/network/identity.py @@ -55,8 +55,10 @@ def save_identity(identity: RNS.Identity, path: str, replace: bool = True) -> bo def _restrict_permissions(path: str): """Make an existing identity file owner-only if group or others could access it (e.g. an older rnfs's). - On Windows, which has no Unix permission bits (st_mode always shows group and others allowed), its access - list is checked and tightened instead (winacl). + Never through a symlink: a symlink at path is left alone, with a warning, and the file is opened without + following one (O_NOFOLLOW) and changed through that descriptor, so a link put there can't get another + file's permissions changed. On Windows, which has no Unix permission bits (st_mode always shows group and + others allowed), its access list is checked and tightened instead (winacl). """ if fsutil.IS_WINDOWS: try: @@ -69,11 +71,19 @@ def _restrict_permissions(path: str): f"restricted it to you and SYSTEM", LOG_WARNING) return try: - mode = stat.S_IMODE(os.stat(path).st_mode) - if mode & 0o077: - os.chmod(path, IDENTITY_MODE) - log(f"identity file {path} was accessible to other users (mode {mode:o}); " - f"restricted it to {IDENTITY_MODE:o}", LOG_WARNING) + if stat.S_ISLNK(os.lstat(path).st_mode): + log(f"identity file {path} is a symlink, so its permissions weren't checked; make sure only you " + f"can read the file it points to", LOG_WARNING) + return + fd = os.open(path, os.O_RDONLY | fsutil.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0)) + try: + mode = stat.S_IMODE(os.fstat(fd).st_mode) + if mode & 0o077: + os.fchmod(fd, IDENTITY_MODE) + log(f"identity file {path} was accessible to other users (mode {mode:o}); " + f"restricted it to {IDENTITY_MODE:o}", LOG_WARNING) + finally: + os.close(fd) except OSError as e: log(f"could not restrict the permissions of identity file {path}: {e}", LOG_WARNING) diff --git a/tests/security/test_i2_identity_symlink.py b/tests/security/test_i2_identity_symlink.py new file mode 100644 index 0000000..26d2705 --- /dev/null +++ b/tests/security/test_i2_identity_symlink.py @@ -0,0 +1,65 @@ +"""I2 (fix): the identity file's permission tightening followed symlinks, so a symlink put at the identity path got +another file's permissions changed. Now a symlink there is left alone (with a warning) and a regular file is changed +through a descriptor opened with O_NOFOLLOW. + +load_or_create_identity is run on a symlink to a loose file holding a key (it still loads, the target's mode is +unchanged), on a loose regular file (still tightened), and with the path swapped for a symlink between the lstat +and the open (the open refuses it, nothing is changed). +""" +import os +import stat +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs.network import identity as identity_module +from rnfs.network.identity import load_or_create_identity +from tests.support import CAN_SYMLINK, POSIX_MODES + + +def mode_of(path: str) -> int: + """The permission bits of path (following a symlink).""" + return stat.S_IMODE(os.stat(path).st_mode) + + +@unittest.skipUnless(POSIX_MODES and CAN_SYMLINK, "Unix permission bits and symlinks") +class IdentitySymlinkTest(unittest.TestCase): + """storage/identity under a temporary directory, and another file of the user's outside it.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + os.mkdir(os.path.join(self.tmp.name, "storage")) + self.path = os.path.join(self.tmp.name, "storage", "identity") + self.other = os.path.join(self.tmp.name, "elsewhere") + self.identity = RNS.Identity() + self.identity.to_file(self.other) + os.chmod(self.other, 0o644) + + def test_symlink_target_is_not_changed(self): + os.symlink(self.other, self.path) + with mock.patch.object(identity_module, "log") as log: + loaded = load_or_create_identity(self.path) + self.assertEqual(loaded.hash, self.identity.hash) # still the user's choice to link it + self.assertEqual(mode_of(self.other), 0o644, "the permission fix followed the symlink") + self.assertIn("is a symlink", log.call_args.args[0]) + + def test_loose_regular_file_is_still_tightened(self): + os.rename(self.other, self.path) + with mock.patch.object(identity_module, "log"): + load_or_create_identity(self.path) + self.assertEqual(mode_of(self.path), 0o600) + + def test_symlink_swapped_in_after_the_check_is_not_followed(self): + os.symlink(self.other, self.path) + regular = os.stat(self.other) # what lstat would have seen before the swap + with mock.patch("os.lstat", return_value=regular), mock.patch.object(identity_module, "log") as log: + identity_module._restrict_permissions(self.path) + self.assertEqual(mode_of(self.other), 0o644, "the permission fix followed the symlink") + self.assertIn("could not restrict", log.call_args.args[0]) + + +if __name__ == "__main__": + unittest.main() From 8ecad5b33f7c9c24dcf9986519e3f489b89b7c0b Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:39:19 +1030 Subject: [PATCH 045/107] Make name publish crash-consistent and reconcile name, follow and LXMF pins at start --- CHANGELOG.md | 8 + design.md | 11 +- rnfs/content/storage.py | 15 +- rnfs/features/follow.py | 30 ++- rnfs/features/lxmf.py | 15 ++ rnfs/names.py | 93 ++++++++- rnfs/node.py | 5 +- specification.md | 13 +- tests/security/test_new2_follow_lxmf_pins.py | 140 +++++++++++++ .../security/test_new2_name_publish_crash.py | 186 ++++++++++++++++++ 10 files changed, 493 insertions(+), 23 deletions(-) create mode 100644 tests/security/test_new2_follow_lxmf_pins.py create mode 100644 tests/security/test_new2_name_publish_crash.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 949c7e8..9306b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,14 @@ Publishing, names and storage: the node itself again, and a round sends only the withdrawals still queued. - Where a publish round stored each key is saved as the round goes (every 10 nodes or 30 s), so records stored by a round cut short by a crash are withdrawn later; they used to stay until they expired. +- `name publish` is crash-consistent: it pins the new target, saves `names.json`, then releases the old + target, and a failed save (a full disk) undoes the publish. A crash or failed save used to leave the name + recorded at a target no longer pinned (which `gc` deleted) and the new one pinned by `name:`, which + `pin rm` refused and `name rm` didn't release. At start the node now makes `name:` pins match `names.json`, + releases `follow:` pins of names no longer followed and `lxmf:` pins no saved inbox message records, which + a crash inside `name unfollow` or before the inbox was saved used to leave for good. A followed name's check + releases every other target its pin source holds. A pin or unpin whose `pins.json` save fails no longer + stays changed in memory. ## 0.1.3 diff --git a/design.md b/design.md index f225a33..654f2d5 100644 --- a/design.md +++ b/design.md @@ -551,8 +551,15 @@ Everything in the specification is implemented, with these simplifications and a (`NameManager.record` or the kept record) from two keepers, at most `keeper_rounds()` batches (3; 2 on slow nodes, where one current answer is enough once a sequence is known). - **Names.** A key imported, or found without an entry in `names.json`, is marked `imported`: its first publish - offers kept records, makes a path request and queries keepers before choosing a sequence. The pins of name - keys deleted by hand are released by `release_gone()`, once the features are set up. The name table marks + offers kept records, makes a path request and queries keepers before choosing a sequence. `publish()` pins + the new target, saves `names.json` (the commit point; a failed save rolls the record back and releases the + new pin) and only then releases the old target, so `names.json`'s target is pinned at every step. Once the + features are set up, `reconcile_pins(storage.pins())` releases the pins of name keys deleted by hand + (`release_gone()`), pins a recorded target again if its pin is missing, and releases `name:` pins nothing + records; a key without a `names.json` entry keeps its pins until its next publish or removal (`_stale`, + where a release that failed with `OSError` also waits). `Storage.pin`/`unpin` undo their change in memory + if saving `pins.json` fails. Follow and LXMF do the same for their sources at start + (`_release_unfollowed`, `_release_unrecorded`). The name table marks entries that were only heard (`_heard_only`, its own LRU, so finding one to evict is O(1)); an announce with no waiting resolve, of a name without a kept record, may only evict one of those. A resolve with `persist=False` (gateway, LXMF auto-fetch) raises the name's floor (`_floor`), which `_offer` checks. diff --git a/rnfs/content/storage.py b/rnfs/content/storage.py index 55a57a5..743e4d6 100644 --- a/rnfs/content/storage.py +++ b/rnfs/content/storage.py @@ -320,7 +320,14 @@ def pin(self, cid: Cid, source: str): sources = self._pins.setdefault(cid, set()) if source not in sources: sources.add(source) - self._save_pins() + try: + self._save_pins() + except BaseException: + # Not saved (a full or failing disk): not pinned, so memory says what pins.json does + sources.discard(source) + if not sources: + del self._pins[cid] + raise def unpin(self, cid: Cid, source: str) -> bool: """Release source's hold on cid; True if it held it. Blocks go at the next evict() or gc().""" @@ -332,7 +339,11 @@ def unpin(self, cid: Cid, source: str) -> bool: if not sources: del self._pins[cid] self._maybe_garbage = True - self._save_pins() + try: + self._save_pins() + except BaseException: + self._pins.setdefault(cid, set()).add(source) # still pinned, as pins.json says + raise return True def pins(self) -> dict[Cid, set[str]]: diff --git a/rnfs/features/follow.py b/rnfs/features/follow.py index 9f48b8d..f4e5f1a 100644 --- a/rnfs/features/follow.py +++ b/rnfs/features/follow.py @@ -75,6 +75,7 @@ def __init__(self, node): self._keepers = node.feature("keepers") self._keepers.add_listener(self._changed) self._load() + self._release_unfollowed() # State @@ -94,6 +95,21 @@ def _load(self): if len(name) == ID_LENGTH: self._pinned[name] = pinned + def _release_unfollowed(self): + """Release follow pins of names no longer followed: a crash inside unfollow, after follow.json was saved, + leaves one that nothing else would release (`pin rm` releases only the user's pins).""" + for cid, sources in self.node.storage.pins().items(): + for source in sources: + if not source.startswith(SOURCE_PREFIX): + continue + try: + name = bytes.fromhex(source[len(SOURCE_PREFIX):]) + except ValueError: + continue + if name not in self._pinned: + _log(f"releasing {cid}, pinned for name {name.hex()}, which is no longer followed", LOG_WARNING) + self.node.storage.unpin(cid, source) + def _save(self): """Write follow.json atomically (lock held).""" from rnfs.content.storage import write_json @@ -148,8 +164,10 @@ def unfollow(self, name: bytes): self._failures.pop(name, None) self._resolved.discard(name) self._save() - if pinned is not None: - self.node.storage.unpin(pinned, follow_source(name)) + # Every target its source holds: a crash during an earlier check may have left more than the one recorded + source = follow_source(name) + for cid in self.node.storage.pinned_by(source) | ({pinned} if pinned is not None else set()): + self.node.storage.unpin(cid, source) try: self._keepers.unkeep(name, FOLLOW) except NotFound: @@ -232,9 +250,11 @@ def check(self, name: bytes, network: bool = True) -> bool: return False if not followed: return True # unfollowed while fetching: nothing was pinned - # The old target is released only now that the new one is complete - if previous is not None and previous != target: - self.node.storage.unpin(previous, source) + # The old target is released only now that the new one is complete, with any other the source holds (a + # crash between pinning and saving follow.json leaves the target pinned but not recorded) + for cid in self.node.storage.pinned_by(source) | ({previous} if previous is not None else set()): + if cid != target: + self.node.storage.unpin(cid, source) self.node.publisher.refresh() self._set_state(name, "current") _log(f"name {name.hex()} now pinned at {target}", LOG_NOTICE) diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index dca2fe6..cc68815 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -674,6 +674,7 @@ def __init__(self, node): # LXMF doesn't persist its outbound queue self.outbox.update(entry["id"], state="failed", error="the daemon stopped before it was delivered") self._lock = threading.RLock() # inbox removals and auto-fetch pins, so a pin is never left behind + self._release_unrecorded() self._resend: dict[str, tuple] = {} # direct message hash -> (address, content, fields, nearest) self._told_no_pn = False # logged once how to set a propagation node self._auto_queue: deque = deque() @@ -682,6 +683,20 @@ def __init__(self, node): self._wake = threading.Event() self._threads: list[threading.Thread] = [] + def _release_unrecorded(self): + """Release lxmf: pins no inbox message records as pinned. Pins are saved at once and the inbox every + few seconds, so a crash can leave a pin whose message is gone or still queued; nothing else would release + it (`pin rm` releases only the user's pins). A queued link is fetched and pinned again at start().""" + from rnfs.content.storage import LXMF_PREFIX + recorded = {(entry.get("from"), result.get("cid")) for entry in self.inbox.entries() + for result in entry.get("auto") or [] if isinstance(result, dict) and result.get("state") == "pinned"} + for cid, sources in self.node.storage.pins().items(): + for source in sources: + if source.startswith(LXMF_PREFIX) and (source[len(LXMF_PREFIX):], str(cid)) not in recorded: + _log(f"releasing {cid}, pinned for a message from {source[len(LXMF_PREFIX):]} that the inbox " + "no longer records", LOG_WARNING) + self.node.storage.unpin(cid, source) + @staticmethod def _propagated_sent(entry: dict) -> bool: """A propagated message is done once the propagation node has it.""" diff --git a/rnfs/names.py b/rnfs/names.py index 1cf28ab..93a2a1b 100644 --- a/rnfs/names.py +++ b/rnfs/names.py @@ -210,8 +210,8 @@ def received_announce(self, destination_hash, announced_identity, app_data, anno class NameManager: """This node's name keys and records, and the records it has heard. Thread-safe. - Lifecycle: construct (loads keys and state, no RNS needed), release_gone() once the node and its features are - set up, start() once Reticulum runs, run(stop_event) on a thread for re-announces, stop() at shutdown. + Lifecycle: construct (loads keys and state, no RNS needed), reconcile_pins() once the node and its features + are set up, start() once Reticulum runs, run(stop_event) on a thread for re-announces, stop() at shutdown. """ def __init__(self, config: Config, pins, network=None, clock=time.time, monotonic=time.monotonic): @@ -255,6 +255,12 @@ def __init__(self, config: Config, pins, network=None, clock=time.time, monotoni self.keepers = None # Pins of names whose key file was deleted by hand, released by release_gone() once the node is set up self._gone: list[tuple[str, Cid]] = [] + # Aliases with an entry in names.json: for these the recorded target (or none) is the only CID their pin + # source may hold (reconcile_pins). A key without one (names.json lost) may have pins nothing records + self._recorded: set[str] = set() + # alias -> CIDs its pin source holds that its record doesn't (left by a crash, or an unpin that failed): + # released by its next publish or removal + self._stale: dict[str, set[Cid]] = {} self._remove_old_temps() self._load_keys() @@ -300,8 +306,11 @@ def remove(self, alias: str): with self._lock: self._identity(alias) published = self._published.pop(alias, None) + held = self._stale.pop(alias, set()) if published is not None and published.cid is not None: - self._pins.unpin(published.cid, PIN_SOURCE_PREFIX + alias) + held.add(published.cid) + self._release(alias, held) + self._stale.pop(alias, None) # one that failed is released at the next start (reconcile_pins) self._next_announce.pop(alias, None) destination = self._destinations.pop(alias, None) if destination is not None: @@ -309,6 +318,7 @@ def remove(self, alias: str): name = self._names.pop(alias) del self._aliases[name] del self._keys[alias] + self._recorded.discard(alias) # State first: a crash before the key file is gone leaves an unpublished name, not a lost pin self._save() key_path = self._key_path(alias) @@ -332,6 +342,57 @@ def release_gone(self): if gone: self._save() # its entry is dropped only once its pin is released + def reconcile_pins(self, pins: dict[Cid, set[str]]): + """Make the name pins (pins: every pin and its sources, as storage.pins() gives them) match names.json, + once the node is set up: release_gone(), then pin each recorded target that lacks its name's pin and + release name pins nothing records (a crash inside publish() can leave one, and earlier rnfs versions + could leave one that `pin rm` and `name rm` couldn't remove). + + A key with no names.json entry (the file was lost) keeps its pins until its next publish or removal, + which release them; a pin of an alias with no key at all is released. + """ + self.release_gone() + with self._lock: + held: dict[str, set[Cid]] = {} + for cid, sources in pins.items(): + for source in sources: + if source.startswith(PIN_SOURCE_PREFIX): + held.setdefault(source[len(PIN_SOURCE_PREFIX):], set()).add(cid) + for alias in sorted(set(held) | set(self._keys)): + cids = held.get(alias, set()) + if alias not in self._keys: + log(f"releasing the pins of name {alias}, which has no key", LOG_WARNING) + self._release(alias, cids) + self._stale.pop(alias, None) # retried at the next start + continue + recorded = self._published[alias].cid + if alias not in self._recorded: + if cids: + log(f"name {alias} has no record in {STATE_FILE}; its pins are released at its next " + "publish", LOG_WARNING) + self._stale.setdefault(alias, set()).update(cids) + continue + if recorded is not None and recorded not in cids: + log(f"pinning {recorded} again for name {alias}, as {STATE_FILE} records", LOG_WARNING) + try: + self._pins.pin(recorded, PIN_SOURCE_PREFIX + alias) + except OSError as e: # e.g. a read-only state directory: start all the same + log(f"could not pin {recorded} for name {alias}: {e}", LOG_ERROR) + extra = cids - {recorded} + if extra: + log(f"releasing {len(extra)} pin(s) of name {alias} that {STATE_FILE} doesn't record", LOG_WARNING) + self._release(alias, extra) + + def _release(self, alias: str, cids): + """Release alias's pin source from cids; one that fails (a disk error saving pins) is logged and kept in + _stale, for the next publish or removal. Lock held.""" + for cid in cids: + try: + self._pins.unpin(cid, PIN_SOURCE_PREFIX + alias) + except OSError as e: + log(f"could not release {cid} from name {alias}: {e}", LOG_ERROR) + self._stale.setdefault(alias, set()).add(cid) + def name(self, alias: str) -> bytes: """alias's name. NotFound if no such alias.""" with self._lock: @@ -377,15 +438,27 @@ def publish(self, alias: str, cid: Cid) -> dict: raise RnfsError(f"name {alias} has reached the highest sequence") source = PIN_SOURCE_PREFIX + alias - # Pin the new target before releasing the old one, so blocks they share are never unprotected + # Crash-consistent: pin the new target, then save names.json (the commit point), then release the old + # target. A crash leaves at most an extra pin of this source, which reconcile_pins() releases at the + # next start, and the recorded target is pinned at every step (blocks they share stay protected) self._pins.pin(cid, source) - if previous.cid is not None and previous.cid != cid: - self._pins.unpin(previous.cid, source) - + held_before = self._held.get(name) self._published[alias] = _Published(sequence, cid) - self._drop_from_table(name) self._held[name] = Record(sequence, cid, self._clock()) - self._save() + try: + self._save() + except Exception: + # Not published: back to what names.json still says (ENOSPC, EIO) + self._published[alias] = previous + if held_before is None: + self._held.pop(name, None) + else: + self._held[name] = held_before + if cid != previous.cid and cid not in self._stale.get(alias, set()): + self._release(alias, {cid}) + raise + self._drop_from_table(name) + self._release(alias, ({previous.cid} | self._stale.pop(alias, set())) - {cid, None}) self._announce(alias) identity = self._keys[alias] log(f"published {name.hex()} ({alias}) -> {cid}, sequence {sequence}", LOG_NOTICE) @@ -753,6 +826,7 @@ def _add_key(self, alias: str, identity: RNS.Identity, published: _Published): self._aliases[name] = alias self._published[alias] = published self._save() + self._recorded.add(alias) if self._started: self._open_destination(alias) @@ -810,6 +884,7 @@ def _load_state(self): published = _Published(int(entry["sequence"]), cid, bool(entry.get("imported", False))) if alias in self._keys: self._published[alias] = published + self._recorded.add(alias) elif cid is not None: # Its key file was deleted by hand: nothing will republish it, so release its pin self._gone.append((alias, cid)) diff --git a/rnfs/node.py b/rnfs/node.py index c998b61..3f82841 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -313,8 +313,9 @@ def _setup(self, config: Config, node_id: bytes, client, slow: bool, names_netwo self.features: dict[str, features.Feature] = {} for cls in features.feature_classes(): self.features[cls.name] = cls(self) - # Releasing a deleted name key's pin recomputes what is provided, so it waits for the features too - self.names.release_gone() + # Releasing a deleted name key's pin recomputes what is provided, so it waits for the features too; and + # name pins are made to match names.json, which a crash inside a publish can leave them disagreeing with + self.names.reconcile_pins(self.storage.pins()) # What is provided, before /blocks can be asked; after the features, whose keys (kept names) would # otherwise all be withdrawn at every start self.publisher.refresh() diff --git a/specification.md b/specification.md index 206d3ab..5bdb459 100644 --- a/specification.md +++ b/specification.md @@ -603,6 +603,11 @@ and repeat. - `pin rm` removes only the `user` source. Other sources are released by whatever created them: the folder scan; `name publish` or `name rm`; `/pin` with `false` or a remote quota being exceeded; `name unfollow`, or a followed name's newer target once that is complete; removing the LXMF messages that hold it. +- Pins are saved at once; the state that records why a source holds a pin (`names.json`, `follow.json`, the + LXMF inbox) is saved separately, so a crash can leave them disagreeing. At start the node makes `name:` pins + match `names.json` (pinning a recorded target again, releasing the rest), and releases `follow:` pins of + names no longer followed and `lxmf:` pins no saved inbox message records (a queued auto-fetch is fetched + again). A followed name's check releases every other target its source holds. - A pin is provided only once it is complete. Blocks of fetches, adds and imports in progress, and of queued or running jobs (section 11), are protected as if pinned. @@ -678,10 +683,12 @@ A **name** is a mutable pointer to a CID, owned by a key pair: the IPNS equivale **Publish** (`name publish [/path]`): 1. Resolve any path to a CID locally. -2. Pin the target with source `name:`, and release that source from the previous target. +2. Pin the target with source `name:`. 3. Set the record with `sequence` one higher than both the publisher's stored sequence and the highest it can - resolve, and persist it. -4. Announce, and re-announce every `announce_interval`. + resolve, and persist it. If that fails, the publish fails and the pin of step 2 is released. +4. Release that source from the previous target (only now, so a crash leaves the target the persisted record + names pinned; any other pin of the source is released at the next start, section 8). +5. Announce, and re-announce every `announce_interval`. Before the first publish of a key imported from elsewhere, or found without stored state, the node resolves the name through the network and asks keepers (below), since the key's earlier records may be held only there. diff --git a/tests/security/test_new2_follow_lxmf_pins.py b/tests/security/test_new2_follow_lxmf_pins.py new file mode 100644 index 0000000..0cced6d --- /dev/null +++ b/tests/security/test_new2_follow_lxmf_pins.py @@ -0,0 +1,140 @@ +"""NEW-2 (the same pattern in other pin sources): follow: and lxmf: pins that a crash left unrecorded were never released. + +- follow: unfollow saves follow.json before releasing the pin, and check() pins the new target before saving + follow.json and only then releases the target recorded there. A crash in between left a `follow:` pin + that no later check, unfollow or `pin rm` released. +- lxmf: an auto-fetched pin is saved at once, the inbox only every few seconds, so a crash could leave an + `lxmf:` pin whose message the saved inbox doesn't have; inbox removal could never release it. + +The crash is a BaseException from a wrapped call, and the node or feature is then built again on the same +directory without stop(), as in a real crash. +""" +import io +import os +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs.config import Config +from rnfs.content.cid import Cid +from rnfs.content.storage import USER +from rnfs.features import lxmf +from rnfs.features.follow import follow_source +from rnfs.node import RnfsNode +from tests.test_lxmf import SENDER, FeatureTestCase +from tests.test_names import FakeNetwork +from tests.test_names_keep import KeeperNet + +A_ID, B_ID = bytes([0xA] * 16), bytes([0xB] * 16) + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the nodes' log.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class Crash(BaseException): + """Stands in for the process dying.""" + + +class FollowCrashTest(unittest.TestCase): + """Owner A publishes a name that follower B follows; offline nodes.""" + + def setUp(self): + """Two nodes that know each other, and A's name.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.net = KeeperNet() + self.net.sender = B_ID + self.a = self.make_node("a", A_ID) + self.b = self.make_node("b", B_ID) + self.a.routing.heard(B_ID) + self.b.routing.heard(A_ID) + self.name = bytes.fromhex(self.a.name_create("site")["name"]) + self.source = follow_source(self.name) + + def make_node(self, label: str, node_id: bytes) -> RnfsNode: + """An offline node on its own directory (a restart if it was used before).""" + config = Config(rnfs_dir=os.path.join(self.tmp.name, label), slow="no", lookup_timeout=5, path_timeout=0.2) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork()) + self.net.nodes[node_id] = node + return node + + def publish(self, data: bytes, sequence: int, follower: RnfsNode) -> Cid: + """Point A's name at new content and tell follower of the record.""" + cid = Cid.parse(self.a.add_stream(io.BytesIO(data))["cid"]) + self.a.name_publish("site", str(cid)) + self.a.records.store(cid.key, A_ID) + follower.names.offer(self.name, sequence, cid, 0.0) + return cid + + def test_crash_inside_unfollow_leaves_no_pin_after_restart(self): + """Crash after follow.json no longer lists the name, before its pin is released.""" + follow = self.b.feature("follow") + cid1 = self.publish(b"version one", 1, self.b) + follow.follow(self.name) + self.assertTrue(follow.check(self.name, network=False)) + with mock.patch.object(self.b.storage, "unpin", side_effect=Crash()): + with self.assertRaises(Crash): + follow.unfollow(self.name) + b = self.make_node("b", B_ID) + self.addCleanup(b.stop) + # NEW-2: fails until follow pins of names no longer followed are released at start + self.assertEqual(b.storage.pinned_by(self.source), set(), f"{cid1} stays pinned for a name not followed") + + def test_crash_between_pinning_and_saving_is_cleaned_up_by_the_next_move(self): + """Crash after pinning the new target, before follow.json records it; the name then moves again.""" + follow = self.b.feature("follow") + self.publish(b"version one", 1, self.b) + follow.follow(self.name) + self.assertTrue(follow.check(self.name, network=False)) + self.publish(b"version two", 2, self.b) + with mock.patch.object(follow, "_save", side_effect=Crash()): + with self.assertRaises(Crash): + follow.check(self.name, network=False) + b = self.make_node("b", B_ID) + self.addCleanup(b.stop) + b.routing.heard(A_ID) + cid3 = self.publish(b"version three", 3, b) + self.assertTrue(b.feature("follow").check(self.name, network=False)) + # NEW-2: fails until a check releases every other target its follow source holds, not only the recorded one + self.assertEqual(b.storage.pinned_by(self.source), {cid3}) + b.feature("follow").unfollow(self.name) + self.assertEqual(b.storage.pinned_by(self.source), set()) + + +class LxmfCrashTest(FeatureTestCase): + """B auto-fetches a link from a trusted sender, then crashes before its inbox is saved.""" + + def test_pin_of_a_message_the_saved_inbox_lacks_is_released_at_start(self): + feature = self.feature_node(lxmf_auto_fetch_from=[SENDER.hex()]) + kept = feature.received(self.link_message(number=1)) + feature.process_auto_fetches() + feature.save() # the first message and its pin are saved + target = Cid.parse(feature.inbox.get(kept)["auto"][0]["cid"]) + other = Cid.parse(self.a.add_stream(io.BytesIO(os.urandom(2000)))["cid"]) + lost = feature.received(self.link_message(lxmf.Link(other, from_node=A_ID), number=2)) + feature.process_auto_fetches() + self.assertEqual(feature.inbox.get(lost)["auto"][0]["state"], "pinned") + self.b.storage.pin(other, USER) # another source's pin is never touched + # Crash before the inbox is saved: the feature is built again from what is on disk + again = lxmf.LxmfFeature(self.b) + source = f"lxmf:{SENDER.hex()}" + # NEW-2: fails until lxmf: pins the saved inbox doesn't record are released at start + self.assertEqual(self.b.storage.pinned_by(source), {target}) + self.assertEqual(self.b.storage.pins()[other], {USER}) + self.assertEqual(again.inbox_rm(kept), 1) + self.assertEqual(self.b.storage.pinned_by(source), set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_new2_name_publish_crash.py b/tests/security/test_new2_name_publish_crash.py new file mode 100644 index 0000000..c72cc4c --- /dev/null +++ b/tests/security/test_new2_name_publish_crash.py @@ -0,0 +1,186 @@ +"""NEW-2: a crash or a failed save inside `name publish` left a `name:` pin nothing could remove. + +publish() pinned the new target, released the old one (each saved to pins.json at once) and only then saved +names.json. A crash in between left names.json pointing at the old target, now unpinned (gc deleted it while the +name was announced at it), and the new target pinned by `name:`, which `pin rm` refuses and `name rm` and +later publishes never released. A full disk at names.json had the same effect without a crash. These tests crash +an offline node at each step of a publish (a BaseException from a wrapped pin store, as the reviewer's script +does), restart it on the same directory and check that the name pins match names.json, that gc keeps the +recorded target and that `name rm` leaves no pin; and they make the saves fail with OSError. +""" +import errno +import io +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.content.cid import Cid +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_node import NetClient + +A_ID = bytes([0xA] * 16) +SOURCE = "name:site" + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node's log.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class Crash(BaseException): + """Stands in for the process dying.""" + + +class FailingPins: + """The NameManager's pins, failing at a chosen step: crash or raise OSError before or after pin or unpin.""" + + def __init__(self, pins, fail: str, error: BaseException): + """fail is "after pin", "before unpin" or "" (never); error is what is raised there.""" + self.pins = pins + self.fail = fail + self.error = error + + def pin(self, cid, source): + """Pin, then fail if asked to.""" + self.pins.pin(cid, source) + if self.fail == "after pin": + self.fail = "" + raise self.error + + def unpin(self, cid, source): + """Fail if asked to, else unpin.""" + if self.fail == "before unpin": + self.fail = "" + raise self.error + return self.pins.unpin(cid, source) + + +class NamePublishCrashTest(unittest.TestCase): + """An offline node publishing a name at one cached target, then at another.""" + + def setUp(self): + """A temporary directory for the node.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def open_node(self, root: str) -> RnfsNode: + """An offline node on root (a restart when root was used before).""" + net = NetClient() + net.sender = A_ID + node = RnfsNode.offline(Config(rnfs_dir=root, slow="no", lookup_timeout=1, path_timeout=0.2), A_ID, net, + FakeNetwork()) + net.nodes[A_ID] = node + return node + + def first_publish(self, root: str) -> tuple[RnfsNode, Cid, Cid]: + """A node with two cached (unpinned) targets, "site" published at the first.""" + node = self.open_node(root) + cid1 = Cid.parse(node.add_stream(io.BytesIO(os.urandom(5000)), pin=False)["cid"]) + cid2 = Cid.parse(node.add_stream(io.BytesIO(os.urandom(5000)), pin=False)["cid"]) + node.name_create("site") + node.name_publish("site", str(cid1)) + return node, cid1, cid2 + + @staticmethod + def name_pins(node: RnfsNode) -> set[Cid]: + """The CIDs the name's pin source holds.""" + return node.storage.pinned_by(SOURCE) + + def test_crash_at_each_step_leaves_pins_matching_names_json(self): + """After the restart, the name's pins are exactly its recorded target, which gc keeps and `name rm` + releases, wherever the publish crashed.""" + problems = [] + for step in ("after pin", "in save", "before unpin"): + with self.subTest(step=step): + root = os.path.join(self.tmp.name, step.replace(" ", "-")) + a, cid1, cid2 = self.first_publish(root) + a.names._pins = FailingPins(a.names._pins, step, Crash()) + if step == "in save": + def crash(): + raise Crash() + a.names._save = crash + with self.assertRaises(Crash): + a.name_publish("site", str(cid2)) + # a is abandoned without stop(), as in a real crash: only what it wrote survives + + b = self.open_node(root) + recorded = Cid.parse(b.name_list()[0]["cid"]) + if self.name_pins(b) != {recorded}: + problems.append(f"{step}: name pins {sorted(map(str, self.name_pins(b)))} but names.json " + f"records {recorded}") + b.gc() + if not b.storage.is_complete(recorded): + problems.append(f"{step}: gc deleted the recorded target {recorded}") + b.name_rm("site") + if self.name_pins(b): + problems.append(f"{step}: `name rm` left {sorted(map(str, self.name_pins(b)))} pinned") + b.stop() + # NEW-2: fails until publish saves names.json between pinning the new target and releasing the old one, + # and the node reconciles name pins with names.json at start + self.assertEqual(problems, []) + + def test_failed_save_rolls_the_publish_back(self): + """names.json can't be written (ENOSPC): the publish fails and nothing changes, in memory or on disk.""" + a, cid1, cid2 = self.first_publish(os.path.join(self.tmp.name, "a")) + save = a.names._save + + def full(): + a.names._save = save + raise OSError(errno.ENOSPC, "No space left on device") + a.names._save = full + with self.assertRaises(OSError): + a.name_publish("site", str(cid2)) + listed = a.name_list()[0] + # NEW-2: fails until a failed names.json save rolls back the publish's pin changes + self.assertEqual(self.name_pins(a), {cid1}) + self.assertEqual((listed["cid"], listed["sequence"]), (str(cid1), 1)) + self.assertEqual(a.name_publish("site", str(cid2))["sequence"], 2) # trying again works + self.assertEqual(self.name_pins(a), {cid2}) + a.stop() + + def test_failed_release_of_the_old_target_is_retried(self): + """Releasing the old target fails (pins.json can't be written): the publish stands, and the next one + releases it.""" + a, cid1, cid2 = self.first_publish(os.path.join(self.tmp.name, "a")) + a.names._pins = FailingPins(a.names._pins, "before unpin", OSError(errno.EIO, "Input/output error")) + try: + result = a.name_publish("site", str(cid2)) + except OSError as e: + # NEW-2: fails until a release that fails after names.json was saved is kept for later, not raised + self.fail(f"the publish failed after pinning its new target: {e}") + self.assertEqual(result["sequence"], 2) + self.assertEqual(self.name_pins(a), {cid1, cid2}) # cid1 still held: nothing was lost + cid3 = Cid.parse(a.add_stream(io.BytesIO(os.urandom(5000)), pin=False)["cid"]) + a.name_publish("site", str(cid3)) + self.assertEqual(self.name_pins(a), {cid3}) + a.stop() + + def test_name_pins_nothing_records_are_released_at_start(self): + """Pins left by an earlier version's crash, or of an alias with no key, are released when the node starts; + a recorded target missing its pin is pinned again.""" + root = os.path.join(self.tmp.name, "a") + a, cid1, cid2 = self.first_publish(root) + a.storage.pin(cid2, SOURCE) # what the old order left after a crash in its save + a.storage.unpin(cid1, SOURCE) + a.storage.pin(cid2, "name:gone") # an alias whose key and record are both gone + a.stop() + b = self.open_node(root) + # NEW-2: fails until name pins are reconciled with names.json at start + self.assertEqual(self.name_pins(b), {cid1}) + self.assertEqual(b.storage.pinned_by("name:gone"), set()) + b.stop() + + +if __name__ == "__main__": + unittest.main() From 0cc8193efa42d085c572e6be600131396509559d Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:39:36 +1030 Subject: [PATCH 046/107] Keep trusted LXMF senders' inbox messages under floods of new identities and rate limit the others --- rnfs/features/contacts.py | 27 +++++- rnfs/features/lxmf.py | 80 +++++++++++++--- tests/security/test_l12_lxmf_inbox.py | 133 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 tests/security/test_l12_lxmf_inbox.py diff --git a/rnfs/features/contacts.py b/rnfs/features/contacts.py index b03b49c..8f09b35 100644 --- a/rnfs/features/contacts.py +++ b/rnfs/features/contacts.py @@ -9,7 +9,8 @@ - A target may start with a name contact's nick: `rnfs get alice/docs/report.pdf` resolves alice's name to its CID first (RnfsNode._parse_target calls expand_target; never for the gateway, so web pages can't use or probe the address book). Wherever the CLI takes a node ID (`get --from`, `ping`, `pin remote`), a node - contact's nick works too (node_arg). The LXMF feature looks nicks up with lookup(nick, "lxmf"). + contact's nick works too (node_arg). The LXMF feature looks nicks up with lookup(nick, "lxmf"), and trusts + senders that has(address, "lxmf") finds. - Stored in /contacts.json (atomic, UTF-8), at most MAX_CONTACTS entries. No RNS at module level (the CLI imports this module). @@ -81,6 +82,7 @@ def __init__(self, node): self._path = os.path.join(node.config.state_dir, CONTACTS_FILE) self._lock = threading.Lock() self._contacts: dict[str, tuple[str, bytes]] = {} # nick -> (kind, hash) + self._nicks: dict[tuple[str, bytes], int] = {} # (kind, hash) -> how many nicks name it, for has() # name -> (CID, monotonic time of the resolve that found it), reused for EXPAND_AGE self._expanded: dict[bytes, tuple[str, float]] = {} self._load() @@ -97,12 +99,23 @@ def _load(self): for nick, entry in entries.items(): try: check_nick(nick) - self._contacts[nick] = (check_kind(entry["kind"]), parse_hex(entry["id"])) + self._set(nick, (check_kind(entry["kind"]), parse_hex(entry["id"]))) except (KeyError, TypeError, ValueError): self._log(f"ignoring malformed contact {nick!r} in {self._path}", LOG_WARNING) if len(self._contacts) >= MAX_CONTACTS: break + def _set(self, nick: str, value: tuple[str, bytes] | None): + """Set or (None) delete a contact, keeping _nicks in step (lock held, or loading).""" + old = self._contacts.pop(nick, None) + if old is not None: + self._nicks[old] -= 1 + if not self._nicks[old]: + del self._nicks[old] + if value is not None: + self._contacts[nick] = value + self._nicks[value] = self._nicks.get(value, 0) + 1 + def _save(self): """Write contacts.json atomically (lock held).""" from rnfs.content.storage import write_json @@ -130,7 +143,7 @@ def add(self, nick: str, ident_hex: str, kind: str | None = None) -> dict: with self._lock: if nick not in self._contacts and len(self._contacts) >= MAX_CONTACTS: raise ValueError(f"the address book is full ({MAX_CONTACTS} contacts)") - self._contacts[nick] = (kind, ident) + self._set(nick, (kind, ident)) self._save() self._log(f"added {nick}: {kind} {ident.hex()}") return {"nick": nick, "kind": kind, "id": ident.hex()} @@ -139,8 +152,9 @@ def remove(self, nick: str): """Delete a contact. NotFound if there is none.""" check_nick(nick) with self._lock: - if self._contacts.pop(nick, None) is None: + if nick not in self._contacts: raise NotFound(f"no contact {nick}") + self._set(nick, None) self._save() def list(self) -> list[dict]: @@ -159,6 +173,11 @@ def lookup(self, nick: str, kind: str | None = None) -> bytes | None: return None return entry[1] + def has(self, ident: bytes, kind: str) -> bool: + """Whether some contact of that kind has hash ident (for other features: LXMF trusts its contacts).""" + with self._lock: + return (kind, bytes(ident)) in self._nicks + def get(self, nick: str) -> dict: """One contact. NotFound if there is none; ValueError for an invalid nick.""" check_nick(nick) diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index dca2fe6..fd5816f 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -65,6 +65,10 @@ MAX_SCAN_CHARS = 16 * 1024 # message text searched for links INBOX_LIMIT = 500 # messages kept; the oldest holding no auto-fetched pins is dropped INBOX_PER_SENDER = 50 # messages kept from one sender not in lxmf_auto_fetch_from (senders' identities are free) +# Senders the user named (lxmf_accept_from, lxmf_auto_fetch_from, LXMF contacts) are trusted: their records are +# dropped only for each other's. Others' new records come from a node-wide token bucket, since identities are free +UNTRUSTED_INTERVAL = 60.0 # seconds per token +UNTRUSTED_BURST = 32 MAX_ENTRY_BYTES = 16 * 1024 # an inbox record as JSON: links beyond it are dropped, then text is cut MAX_INBOX_BYTES = 4 * 1024 * 1024 # every inbox record as JSON (held in memory, rewritten on change) OUTBOX_LIMIT = 200 @@ -424,8 +428,9 @@ class MessageLog: """A bounded list of message records with stable ids, saved atomically as UTF-8 JSON. Thread-safe. At most limit records and max_bytes of JSON are kept. To make room, the oldest record protect() is false - for is dropped; a protected one only when every other is protected too. valid() checks records loaded - from the file (malformed ones are dropped). + for is dropped; a protected one only when every other is protected too. add() may also name records that + are dropped only for each other's (reserved). valid() checks records loaded from the file (malformed ones + are dropped). """ def __init__(self, path: str, limit: int, max_bytes: int | None = None, protect=None, valid=None): @@ -461,10 +466,13 @@ def __init__(self, path: str, limit: int, max_bytes: int | None = None, protect= next_id = stored.get("next_id") self._next_id = max([next_id if type(next_id) is int else 1] + [e["id"] + 1 for e in self._entries]) - def add(self, entry: dict, per_from: int | None = None) -> tuple[int | None, list[dict]]: + def add(self, entry: dict, per_from: int | None = None, reserved=None) -> tuple[int | None, list[dict]]: """Append entry with a new id; returns the id (None if entry itself was dropped: every other record is protected and it isn't) and the entries dropped to stay within the bounds. per_from: also keep at most - that many unprotected records with entry's "from".""" + that many unprotected records with entry's "from". reserved(record): records it is true for are dropped + to make room only for an entry it is true for too, and only once no other unprotected record is left + (trusted senders' records, so new identities can't push them out).""" + reserved = reserved or (lambda record: False) with self._lock: entry["id"] = self._next_id self._next_id += 1 @@ -474,13 +482,16 @@ def add(self, entry: dict, per_from: int | None = None) -> tuple[int | None, lis if per_from is not None: same = [e for e in self._entries if e.get("from") == entry.get("from") and not self.protect(e)] dropped += same[:max(0, len(same) - per_from)] + entry_reserved = reserved(entry) while self._over(dropped): gone = {id(e) for e in dropped} - victim = next((e for e in self._entries if id(e) not in gone and e is not entry - and not self.protect(e)), None) + others = [e for e in self._entries if id(e) not in gone and e is not entry and not self.protect(e)] + victim = next((e for e in others if not reserved(e)), None) + if victim is None and entry_reserved and others: + victim = others[0] if victim is None: - # Every other record holds pins: refuse entry rather than release them, unless it holds - # some too (then the oldest goes; only a trusted sender gets here) + # Every other record holds pins (or is reserved, and entry isn't): refuse entry rather than + # drop them, unless it holds pins too (then the oldest goes; only a trusted sender gets here) victim = entry if not self.protect(entry) or len(self._entries) - len(dropped) == 1 else \ next(e for e in self._entries if id(e) not in gone and e is not entry) dropped.append(victim) @@ -677,6 +688,10 @@ def __init__(self, node): self._resend: dict[str, tuple] = {} # direct message hash -> (address, content, fields, nearest) self._told_no_pn = False # logged once how to set a propagation node self._auto_queue: deque = deque() + self.clock = time.monotonic # for the rate limit on senders not trusted (tests set their own) + self._untrusted_tokens = float(UNTRUSTED_BURST) + self._untrusted_at: float | None = None + self._untrusted_dry = False # logged (notice) that the bucket ran dry; verbose until a token is taken self._auto_wanted = threading.Condition() self._stop = threading.Event() self._wake = threading.Event() @@ -945,13 +960,21 @@ def received(self, message: Incoming) -> int | None: auto = sender in self.auto_fetch_from if auto: entry["auto"] = [{"state": "queued"} for _ in entry["links"]] + if not self.trusted(message.source) and not self._untrusted_token(): + _log(f"message {message_hash} from {sender} not kept: too many new messages from senders not in " + f"contacts, lxmf_accept_from or lxmf_auto_fetch_from", + LOG_VERBOSE if self._untrusted_dry else LOG_NOTICE) + self._untrusted_dry = True + return None with self._lock: - # A sender not trusted with auto-fetch gets INBOX_PER_SENDER records, so one can't push out others' - entry_id, dropped = self.inbox.add(entry, per_from=None if auto else INBOX_PER_SENDER) + # A sender not trusted with auto-fetch gets INBOX_PER_SENDER records, so one can't push out others', + # and records of trusted senders are dropped only for each other's: identities are free + entry_id, dropped = self.inbox.add(entry, per_from=None if auto else INBOX_PER_SENDER, + reserved=lambda record: self._trusted_hex(record.get("from"))) self._release(dropped) if entry_id is None: - _log(f"message {message_hash} from {sender} not kept: the inbox is full of messages holding " - f"auto-fetched pins (rnfs inbox rm)", LOG_NOTICE) + _log(f"message {message_hash} from {sender} not kept: the inbox is full of messages from trusted " + f"senders or holding auto-fetched pins (rnfs inbox rm)", LOG_NOTICE) return None _log(f"message {message_hash} from {sender} with {len(entry['links'])} rnfs link(s), inbox #{entry_id}", LOG_NOTICE) @@ -966,6 +989,39 @@ def accepts(self, source: bytes) -> bool: sender = source.hex() return not self.accept_from or sender in self.accept_from or sender in self.auto_fetch_from + def trusted(self, source: bytes) -> bool: + """Whether the user named LXMF address source: in lxmf_accept_from or lxmf_auto_fetch_from, or an LXMF + contact. Their inbox records are dropped only for each other's, their new records aren't rate limited, + and their messages held for an identity take places held for others'.""" + return self._trusted_hex(source.hex()) + + def _trusted_hex(self, sender) -> bool: + """trusted() for an address as 32 hex characters (an inbox record's "from").""" + if sender in self.accept_from or sender in self.auto_fetch_from: + return True + contacts = getattr(self.node, "features", {}).get("contacts") + if contacts is None or not hasattr(contacts, "has") or not isinstance(sender, str): + return False + try: + return bool(contacts.has(bytes.fromhex(sender), "lxmf")) + except ValueError: + return False + + def _untrusted_token(self) -> bool: + """Take a token for a new record from a sender not trusted (UNTRUSTED_BURST, then one per + UNTRUSTED_INTERVAL, node-wide); False if there is none.""" + now = self.clock() + with self._lock: + if self._untrusted_at is not None: + self._untrusted_tokens = min(float(UNTRUSTED_BURST), self._untrusted_tokens + + max(0.0, now - self._untrusted_at) / UNTRUSTED_INTERVAL) + self._untrusted_at = now + if self._untrusted_tokens < 1: + return False + self._untrusted_tokens -= 1 + self._untrusted_dry = False + return True + def inbox_list(self) -> list[dict]: """Inbox messages, oldest first.""" return self.inbox.entries() diff --git a/tests/security/test_l12_lxmf_inbox.py b/tests/security/test_l12_lxmf_inbox.py new file mode 100644 index 0000000..dc29163 --- /dev/null +++ b/tests/security/test_l12_lxmf_inbox.py @@ -0,0 +1,133 @@ +"""L12: with lxmf_accept_from empty (the default), many cheap LXMF identities could push every unprotected record +out of the 500-message inbox: each new sender got its own 50 records. + +Node B runs the lxmf feature on a stand-in network (tests/test_lxmf.py's FeatureTestCase and FakeLxmf). A contact +(an LXMF contact in the contacts feature) and an auto-fetch sender whose fetch failed each leave a record; then +messages with rnfs links arrive from more than INBOX_LIMIT fresh identities, slowly (the clock moves an hour +between them) so a rate limit can't be what saves the records. Both records must still be there: records from +trusted senders (contacts, lxmf_auto_fetch_from, lxmf_accept_from) are dropped only for each other's. Further tests +show a fast flood of new identities is rate limited (a token bucket shared by senders not trusted), so it can't +even cycle out older records from senders not trusted, while trusted senders aren't limited. +""" +import os +import unittest + +from rnfs.features import lxmf +from rnfs.features.lxmf import Link +from tests import test_lxmf +from tests.support import FakeClock +from tests.test_lxmf import SENDER, message + +CONTACT = bytes([0xC0] * 16) +EARLY = bytes([0xE0] * 16) # a sender not trusted, heard before the flood + + +def setUpModule(): + """Silence the node's log.""" + test_lxmf.setUpModule() + + +def tearDownModule(): + """Restore RNS's loglevel.""" + test_lxmf.tearDownModule() + + +class InboxFloodTest(test_lxmf.FeatureTestCase): + """Node B with one LXMF contact, SENDER in lxmf_auto_fetch_from, and a settable clock.""" + + def setUp(self): + """B's feature, its contact, and a clock the tests move.""" + super().setUp() + self.feature = self.feature_node(lxmf_auto_fetch_from=[SENDER.hex()]) + self.b.feature("contacts").add("carol", CONTACT.hex(), kind="lxmf") + self.clock = FakeClock(1000.0) + self.feature.clock = self.clock # the fixed code's rate limit reads it; the old code ignores it + self.junk = Link(self.cid).uri().encode() + self.number = 0 + + def from_new_identity(self, step: float = 0.0): + """A message with an rnfs link from a fresh identity, step seconds after the last.""" + self.clock.now += step + self.number += 1 + return self.feature.received(message(os.urandom(16), self.junk, number=self.number % 256)) + + def test_slow_flood_of_new_identities_keeps_trusted_records(self): + contact = self.feature.received(self.link_message(source=CONTACT, number=1)) + auto = self.feature.received(self.link_message(source=SENDER, number=2)) + self.feature._set_auto(auto, 0, {"state": "failed", "error": "unreachable"}) # no longer holds pins + for _ in range(lxmf.INBOX_LIMIT + 10): + self.from_new_identity(step=3600.0) + self.assertEqual(len(self.feature.inbox), lxmf.INBOX_LIMIT) + # L12: fails until records from contacts and auto-fetch senders are dropped only for each other's + self.assertIsNotNone(self.feature.inbox.get(contact), + "a flood of new identities pushed out a contact's message") + self.assertIsNotNone(self.feature.inbox.get(auto), + "a flood of new identities pushed out an auto-fetch sender's failed message") + + def test_trusted_records_are_dropped_only_for_each_other(self): + self.feature.inbox.limit = 3 + u1 = self.from_new_identity() + c1 = self.feature.received(self.link_message(source=CONTACT, number=1)) + u2 = self.from_new_identity() + # A contact's message makes room by dropping records from senders not trusted, oldest first + c2 = self.feature.received(self.link_message(source=CONTACT, number=2)) + c3 = self.feature.received(self.link_message(source=CONTACT, number=3)) + self.assertEqual([e["id"] for e in self.feature.inbox_list()], [c1, c2, c3], + f"a contact's message dropped another contact's (unknown senders' were {u1}, {u2})") + # The inbox is all contacts' now: a sender not trusted gets no room; the next contact's drops the oldest + self.assertIsNone(self.from_new_identity(step=3600.0)) + c4 = self.feature.received(self.link_message(source=CONTACT, number=4)) + self.assertEqual([e["id"] for e in self.feature.inbox_list()], [c2, c3, c4]) + + def test_fast_flood_of_new_identities_is_rate_limited(self): + early = self.feature.received(self.link_message(source=EARLY, number=1)) + accepted = sum(self.from_new_identity() is not None for _ in range(lxmf.INBOX_LIMIT * 2)) + # L12: fails until records from senders not trusted are rate limited (a shared token bucket) + self.assertLess(accepted, 100, f"{accepted} records from new identities were kept in one instant") + self.assertIsNotNone(self.feature.inbox.get(early), "the flood cycled out an earlier sender's message") + # Trusted senders aren't limited, and tokens come back with time + self.assertIsNotNone(self.feature.received(self.link_message(source=CONTACT, number=1))) + self.assertIsNotNone(self.feature.received(self.link_message(source=SENDER, number=2))) + self.assertIsNone(self.from_new_identity()) + self.assertIsNotNone(self.from_new_identity(step=lxmf.UNTRUSTED_INTERVAL)) + self.assertIsNone(self.from_new_identity()) + + def test_accept_from_senders_are_not_rate_limited(self): + # A user who lists senders in lxmf_accept_from named them: they aren't limited like strangers + self.feature.accept_from = {CONTACT.hex(), EARLY.hex()} + for number in range(lxmf.INBOX_PER_SENDER + 5): + self.feature.received(self.link_message(source=EARLY, number=number)) + senders = [e["from"] for e in self.feature.inbox_list()] + self.assertEqual(senders.count(EARLY.hex()), lxmf.INBOX_PER_SENDER) # the per-sender cap still applies + self.assertIsNone(self.from_new_identity()) # not in lxmf_accept_from: ignored, as before + + def test_trust_follows_the_contacts_feature(self): + # A contact added later protects what is already in the inbox; removing the contact ends that + self.feature.inbox.limit = 2 + early = self.feature.received(self.link_message(source=EARLY, number=1)) + contacts = self.b.feature("contacts") + contacts.add("erin", EARLY.hex(), kind="lxmf") + for _ in range(3): + self.assertIsNotNone(self.from_new_identity(step=3600.0)) # each drops the last stranger's record + self.assertIsNotNone(self.feature.inbox.get(early)) + contacts.remove("erin") + self.assertIsNotNone(self.from_new_identity(step=3600.0)) + self.assertIsNone(self.feature.inbox.get(early)) + + def test_contacts_lookup_by_address(self): + # ContactsFeature.has, which the inbox asks about every record it may drop + from rnfs.features.contacts import ContactsFeature + book = self.b.feature("contacts") + book.add("carol2", CONTACT.hex(), kind="lxmf") + book.add("node", EARLY.hex(), kind="node") + self.assertTrue(book.has(CONTACT, "lxmf")) + self.assertFalse(book.has(EARLY, "lxmf")) # a node contact isn't an LXMF one + book.remove("carol") + self.assertTrue(book.has(CONTACT, "lxmf")) # carol2 still names it + book.add("carol2", EARLY.hex(), kind="lxmf") # replaced + self.assertFalse(book.has(CONTACT, "lxmf")) + self.assertTrue(ContactsFeature(self.b).has(EARLY, "lxmf")) # loaded from contacts.json + + +if __name__ == "__main__": + unittest.main() From 70f91a50b4edc8f59ec123a70af969b40107c664 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:39:36 +1030 Subject: [PATCH 047/107] Update the LXMF inbox flood tests for the rate limit on untrusted senders --- tests/test_lxmf.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_lxmf.py b/tests/test_lxmf.py index 31ed1d2..b2fbcdf 100644 --- a/tests/test_lxmf.py +++ b/tests/test_lxmf.py @@ -22,6 +22,7 @@ from rnfs.features.lxmf import FIELD_CUSTOM_DATA, FIELD_CUSTOM_TYPE, Incoming, Link, MessageLog from rnfs.node import RnfsNode +from tests.support import FakeClock from tests.test_names import FakeNetwork from tests.test_node import NetClient @@ -517,13 +518,16 @@ def test_full_inbox_keeps_pin_holders(self): self.assertNotIn(target, self.b.storage.pins()) def test_flood_from_many_senders_keeps_trusted_pins(self): - # RM-M1 (PoC inbox_flood.py): INBOX_LIMIT messages from fresh identities + # RM-M1 (PoC inbox_flood.py): INBOX_LIMIT messages from fresh identities, slowly enough to pass the rate + # limit on senders not trusted (L12) feature = self.feature_node(lxmf_auto_fetch_from=[SENDER.hex()]) + feature.clock = FakeClock() trusted = feature.received(self.link_message()) feature.process_auto_fetches() target = self.target_cid() junk = Link(self.cid).uri().encode() for i in range(lxmf.INBOX_LIMIT + 10): + feature.clock.now += lxmf.UNTRUSTED_INTERVAL feature.received(message(os.urandom(16), junk, number=i % 256)) self.assertIn(self.source(), self.b.storage.pins()[target]) self.assertIsNotNone(feature.inbox.get(trusted)) @@ -531,8 +535,10 @@ def test_flood_from_many_senders_keeps_trusted_pins(self): def test_one_sender_is_capped(self): feature = self.feature_node(lxmf_auto_fetch_from=[SENDER.hex()]) + feature.clock = FakeClock() junk = Link(self.cid).uri().encode() for i in range(lxmf.INBOX_PER_SENDER + 5): + feature.clock.now += lxmf.UNTRUSTED_INTERVAL # within the rate limit on senders not trusted (L12) feature.received(message(OTHER, junk, number=i)) feature.received(self.link_message(source=bytes([0x54] * 16), number=1)) senders = [e["from"] for e in feature.inbox_list()] From 24898d2b4873f9680391aa0a5b363a5e4374d9b6 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:39:45 +1030 Subject: [PATCH 048/107] Limit held LXMF messages per claimed sender, drop unanswered ones early and let trusted senders take their places --- rnfs/features/lxmf.py | 54 +++++++-- tests/security/test_l13_lxmf_hold_queue.py | 128 +++++++++++++++++++++ 2 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 tests/security/test_l13_lxmf_hold_queue.py diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index fd5816f..62a67a7 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -75,7 +75,9 @@ AUTO_QUEUE_LIMIT = 64 # auto-fetches waiting; more are skipped DELIVERY_LIMIT_KB = 256 # largest LXMF message accepted (LXMF's delivery_limit, in kB); also caps PN downloads HOLD_LIMIT = 32 # messages from senders not known yet, held while their identity is requested; more are dropped +HOLD_PER_SENDER = 8 # of them claiming one sender (the claim is unauthenticated until the identity is known) HOLD_TIME = 120.0 # seconds a held message waits for its sender's identity +HOLD_PATH_WAIT = 30.0 # seconds after its path request (or its arrival, if later) a held message is dropped unanswered # Path requests for held messages' senders: their source is unauthenticated until the identity is known, so # anyone can claim any. One per sender per HOLD_TIME, and node-wide a token bucket (each one floods all # interfaces) @@ -1831,7 +1833,8 @@ def _delivered(self, message): self.feature.received(self._incoming(message)) elif message.unverified_reason == self.LXMF.LXMessage.SOURCE_UNKNOWN and message.packed: # Unsigned as far as we can tell: anyone can send these claiming any source, so the queue - # keeps what it holds (the newest is dropped) and path requests are rate limited + # keeps what it holds (the newest is dropped) unless a trusted sender is claimed, a claimed + # sender gets only HOLD_PER_SENDER places, and path requests are rate limited source, packed = bytes(message.source_hash), bytes(message.packed) if not self.feature.accepts(source): # received() would ignore it: holding it would only let forged sources fill the queue @@ -1839,12 +1842,10 @@ def _delivered(self, message): LOG_VERBOSE) return with self._lock: - queued = len(self._held) < HOLD_LIMIT and all(p != packed for _, p, _ in self._held) - if queued: - self._held.append((time.monotonic() + HOLD_TIME, packed, message.method)) + queued = self._hold(source, packed, message.method) if not queued: - _log(f"dropped a message from unknown sender {source.hex()}: already held, or " - f"{HOLD_LIMIT} are held", LOG_VERBOSE) + _log(f"dropped a message from unknown sender {source.hex()}: already held, " + f"{HOLD_PER_SENDER} held from it, or {HOLD_LIMIT} held", LOG_VERBOSE) return self._request_path(source) _log(f"holding a message from {source.hex()} until its identity is known", LOG_VERBOSE) @@ -1854,6 +1855,32 @@ def _delivered(self, message): except Exception as e: # noqa: BLE001 _log(f"handling a received message failed: {e!r}", LOG_ERROR) + def _hold(self, source: bytes, packed: bytes, method) -> bool: + """Hold a message claiming source until its identity is known (lock held); False if it is dropped: the + same message is held, HOLD_PER_SENDER claiming source are, or HOLD_LIMIT are. A message claiming a + trusted sender (feature.trusted) then takes the place of the newest held for the untrusted sender + holding the most.""" + if any(p == packed for _, p, _ in self._held): + return False + claims: dict[bytes, int] = {} + for _, p, _ in self._held: + claims[p[ID_LENGTH:2 * ID_LENGTH]] = claims.get(p[ID_LENGTH:2 * ID_LENGTH], 0) + 1 + if claims.get(source, 0) >= HOLD_PER_SENDER: + return False + if len(self._held) >= HOLD_LIMIT: + if not self.feature.trusted(source): + return False + others = [item for item in reversed(self._held) + if not self.feature.trusted(item[1][ID_LENGTH:2 * ID_LENGTH])] + if not others: + return False + victim = max(others, key=lambda item: claims[item[1][ID_LENGTH:2 * ID_LENGTH]]) # newest of most + self._held = [item for item in self._held if item is not victim] + _log(f"dropped a held message from unknown sender {victim[1][ID_LENGTH:2 * ID_LENGTH].hex()} for one " + f"from trusted sender {source.hex()}", LOG_VERBOSE) + self._held.append((time.monotonic() + HOLD_TIME, packed, method)) + return True + def _request_path(self, destination: bytes) -> bool: """Request a path to a held message's sender, at most once per HOLD_TIME per sender and within the node-wide token bucket; False if that didn't allow it now (_retry_held tries again).""" @@ -1875,23 +1902,30 @@ def _request_path(self, destination: bytes) -> bool: return True def _retry_held(self): - """Validate held messages whose sender's identity is now known; drop those that waited too long. + """Validate held messages whose sender's identity is now known; drop those that waited too long: their + path request went unanswered for HOLD_PATH_WAIT (counted from their arrival if that is later), or + HOLD_TIME passed. Trusted senders' messages go first, so they get the path-request tokens first. Each stays in the queue until it is done with, so messages arriving meanwhile see it (as a duplicate, and against HOLD_LIMIT).""" with self._lock: held = list(self._held) now = time.monotonic() + held.sort(key=lambda item: not self.feature.trusted(item[1][ID_LENGTH:2 * ID_LENGTH])) for item in held: deadline, packed, method = item source = packed[ID_LENGTH:2 * ID_LENGTH] known = self.RNS.Identity.recall(source) is not None if not known and now < deadline: - self._request_path(source) # if the rate limit held it back before - continue + with self._lock: + asked = self._path_requested.get(source) + if asked is None or now < max(asked, deadline - HOLD_TIME) + HOLD_PATH_WAIT: + self._request_path(source) # if the rate limit held it back before + continue try: if not known: - _log(f"dropped a message from {source.hex()}: its identity never became known", LOG_NOTICE) + why = "its path request got no answer" if now < deadline else "its identity never became known" + _log(f"dropped a message from {source.hex()}: {why}", LOG_NOTICE) continue message = self.LXMF.LXMessage.unpack_from_bytes(packed) message.method = method diff --git a/tests/security/test_l13_lxmf_hold_queue.py b/tests/security/test_l13_lxmf_hold_queue.py new file mode 100644 index 0000000..2be1db1 --- /dev/null +++ b/tests/security/test_l13_lxmf_hold_queue.py @@ -0,0 +1,128 @@ +"""L13: messages from senders whose identity isn't known yet waited in one global queue (32 messages, 120 s each), so +32 forged messages claiming random senders crowded out a real first message, which LXMF has already recorded as +delivered and will not deliver again. + +An LxmfNetwork with a stand-in router and feature (tests/test_lxmf.py's NetworkBoundsTest.network), Reticulum's path +requests and identity recall patched, and time.monotonic set by hand. The tests fill the queue with forgeries and +check what the fix must do: a message claiming a trusted sender (a contact or an auto-fetch sender) takes a slot +held for an unknown one; one claimed sender gets only HOLD_PER_SENDER slots; a message whose path request got no +answer within HOLD_PATH_WAIT is dropped early; and the path-request tokens go to trusted senders first. +""" +import os +import unittest +from unittest import mock + +import RNS + +from rnfs.features import lxmf +from tests import test_lxmf + +CONTACT = bytes([0xC0] * 16) +T0 = 1000.0 + + +def setUpModule(): + """Silence the node's log.""" + test_lxmf.setUpModule() + + +def tearDownModule(): + """Restore RNS's loglevel.""" + test_lxmf.tearDownModule() + + +class HoldQueueTest(unittest.TestCase): + """The hold queue of an LxmfNetwork whose feature trusts CONTACT and accepts everyone.""" + + def setUp(self): + """The network, its path requests recorded, and the time at T0.""" + self.network = test_lxmf.NetworkBoundsTest.network(self) + self.network.feature.accepts.return_value = True + self.trusted = {CONTACT} + self.network.feature.trusted.side_effect = lambda source: source in self.trusted + self.requests = [] + self.now = T0 + for patch in (mock.patch.object(RNS.Transport, "request_path", side_effect=self.requests.append), + mock.patch.object(RNS.Identity, "recall", return_value=None), + mock.patch.object(lxmf.time, "monotonic", side_effect=lambda: self.now)): + patch.start() + self.addCleanup(patch.stop) + + def deliver(self, source: bytes, tag: bytes): + """LXMF hands over a message claiming source, whose identity isn't known.""" + self.network._delivered(mock.Mock(signature_validated=False, + unverified_reason=self.network.LXMF.LXMessage.SOURCE_UNKNOWN, + source_hash=source, packed=os.urandom(16) + source + tag, method=2)) + + def tags(self) -> list[bytes]: + """The tags of the held messages, in queue order.""" + return [packed[2 * lxmf.ID_LENGTH:] for _, packed, _ in self.network._held] + + def fill_with_forgeries(self, count: int = lxmf.HOLD_LIMIT): + """count forged messages claiming random senders.""" + for _ in range(count): + self.deliver(os.urandom(16), b"forged") + self.assertEqual(len(self.network._held), count) + + def test_contact_takes_a_slot_held_for_an_unknown_sender(self): + self.fill_with_forgeries() + self.deliver(CONTACT, b"REAL") + # L13: fails until a contact or auto-fetch sender can take a slot held by an unknown sender + self.assertIn(b"REAL", self.tags(), "a full queue of forgeries dropped a contact's first message") + self.assertEqual(len(self.network._held), lxmf.HOLD_LIMIT) + # Trusted senders push out every forgery but never each other, and a full queue of them refuses strangers + for i in range(lxmf.HOLD_LIMIT): + self.trusted.add(bytes([i]) * 16) + self.deliver(bytes([i]) * 16, b"trusted") + self.assertEqual(self.tags(), [b"REAL"] + [b"trusted"] * (lxmf.HOLD_LIMIT - 1)) + self.deliver(os.urandom(16), b"late") + self.assertNotIn(b"late", self.tags()) + + def test_one_claimed_sender_gets_only_a_few_slots(self): + spoofed = os.urandom(16) + for _ in range(lxmf.HOLD_LIMIT + 8): + self.deliver(spoofed, b"forged") + self.deliver(os.urandom(16), b"REAL") + # L13: fails until the queue limits held messages per claimed sender as well as in total + self.assertIn(b"REAL", self.tags(), "one claimed sender filled the whole queue") + self.assertEqual(self.tags().count(b"forged"), lxmf.HOLD_PER_SENDER) + + def test_unanswered_path_request_frees_the_slot_early(self): + self.fill_with_forgeries() + asked = list(self.requests) + self.assertEqual(len(asked), lxmf.PATH_REQUEST_BURST) + self.now = T0 + 60.0 # well before HOLD_TIME, long after any path response would have come + self.network._retry_held() + held = {packed[lxmf.ID_LENGTH:2 * lxmf.ID_LENGTH] for _, packed, _ in self.network._held} + # L13: fails until a held message whose path request got no answer is dropped before HOLD_TIME + self.assertFalse(held & set(asked), "held messages whose path requests went unanswered still hold slots") + # The senders asked only now (tokens came back) keep their slots until their own wait is over + self.assertTrue(set(self.requests[len(asked):]) <= held) + self.assertEqual(len(held), lxmf.HOLD_LIMIT - len(asked)) + self.deliver(os.urandom(16), b"REAL") + self.assertIn(b"REAL", self.tags()) + + def test_answered_path_request_validates_after_the_early_wait(self): + # The early drop is only for unanswered requests: a sender that becomes known is validated as before + self.deliver(CONTACT, b"REAL") + self.now = T0 + lxmf.HOLD_PATH_WAIT + 1 + message = mock.Mock(signature_validated=True) + with mock.patch.object(RNS.Identity, "recall", return_value=object()), \ + mock.patch.object(self.network.LXMF.LXMessage, "unpack_from_bytes", return_value=message), \ + mock.patch.object(self.network, "_incoming", return_value="incoming"): + self.network._retry_held() + self.network.feature.received.assert_called_once_with("incoming") + self.assertEqual(self.network._held, []) + + def test_path_request_tokens_go_to_trusted_senders_first(self): + self.fill_with_forgeries(lxmf.HOLD_LIMIT - 1) + self.deliver(CONTACT, b"REAL") + self.assertNotIn(CONTACT, self.requests) # the token bucket was empty + self.now = T0 + lxmf.PATH_REQUEST_INTERVAL # one token back + self.network._retry_held() + # L13: fails until held messages from trusted senders get the path-request tokens before unknown ones + self.assertEqual(self.requests[-1], CONTACT, "the token went to a forged sender ahead of a contact") + + +if __name__ == "__main__": + unittest.main() From 84213fa14a31de9617cac99848621284de4ede0e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:39:49 +1030 Subject: [PATCH 049/107] Take LXMF auto-fetches round-robin by sender and share a full queue between senders --- rnfs/features/lxmf.py | 82 ++++++++++++++++--- .../security/test_lxmf_auto_fetch_fairness.py | 73 +++++++++++++++++ 2 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 tests/security/test_lxmf_auto_fetch_fairness.py diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index 62a67a7..9764382 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -72,7 +72,7 @@ MAX_ENTRY_BYTES = 16 * 1024 # an inbox record as JSON: links beyond it are dropped, then text is cut MAX_INBOX_BYTES = 4 * 1024 * 1024 # every inbox record as JSON (held in memory, rewritten on change) OUTBOX_LIMIT = 200 -AUTO_QUEUE_LIMIT = 64 # auto-fetches waiting; more are skipped +AUTO_QUEUE_LIMIT = 64 # auto-fetches waiting (taken round-robin by sender); more are skipped DELIVERY_LIMIT_KB = 256 # largest LXMF message accepted (LXMF's delivery_limit, in kB); also caps PN downloads HOLD_LIMIT = 32 # messages from senders not known yet, held while their identity is requested; more are dropped HOLD_PER_SENDER = 8 # of them claiming one sender (the claim is unauthenticated until the identity is known) @@ -661,6 +661,61 @@ def _fit_entry(entry: dict): entry["title"] = "" +class _RoundRobin: + """Items waiting in a queue per key (an auto-fetch sender), taken one key at a time in turn, so one key's + backlog doesn't hold up another's. Not thread-safe: the caller locks.""" + + def __init__(self): + """Nothing waiting.""" + self._queues: dict = {} # key -> deque of its items; dict order is the turn order + self._count = 0 + + def __len__(self) -> int: + """Items waiting.""" + return self._count + + def __iter__(self): + """The items, in the order popleft() takes them.""" + queues = [list(queue) for queue in self._queues.values()] + for turn in range(max(map(len, queues), default=0)): + yield from (queue[turn] for queue in queues if turn < len(queue)) + + def count(self, key) -> int: + """Items waiting for key.""" + return len(self._queues.get(key, ())) + + def append(self, key, item): + """Add item at the end of key's queue (a new key takes its turn after the others).""" + self._queues.setdefault(key, deque()).append(item) + self._count += 1 + + def popleft(self): + """The next item: the oldest of the key whose turn it is, which then goes last. IndexError if empty.""" + if not self._queues: + raise IndexError("nothing waiting") + key = next(iter(self._queues)) + queue = self._queues.pop(key) + item = queue.popleft() + self._count -= 1 + if queue: + self._queues[key] = queue + return item + + def pop_busiest(self, than: int): + """Remove and return the newest item of the key with the most waiting, if it has over than + 1 (so taking + one from it for a key with than waiting leaves things fairer); else None.""" + key = max(self._queues, key=lambda k: len(self._queues[k]), default=None) + if key is None or len(self._queues[key]) <= than + 1: + return None + self._count -= 1 + return self._queues[key].pop() + + def clear(self): + """Forget everything waiting.""" + self._queues.clear() + self._count = 0 + + # The feature @@ -689,7 +744,7 @@ def __init__(self, node): self._lock = threading.RLock() # inbox removals and auto-fetch pins, so a pin is never left behind self._resend: dict[str, tuple] = {} # direct message hash -> (address, content, fields, nearest) self._told_no_pn = False # logged once how to set a propagation node - self._auto_queue: deque = deque() + self._auto_queue = _RoundRobin() # (entry id, link index) by sender self.clock = time.monotonic # for the rate limit on senders not trusted (tests set their own) self._untrusted_tokens = float(UNTRUSTED_BURST) self._untrusted_at: float | None = None @@ -982,7 +1037,7 @@ def received(self, message: Incoming) -> int | None: LOG_NOTICE) if auto: for index in range(len(entry["links"])): - self._queue_auto_fetch(entry_id, index) + self._queue_auto_fetch(entry_id, index, sender) return entry_id def accepts(self, source: bytes) -> bool: @@ -1174,13 +1229,19 @@ def _start_job(self, target: str, output: str, link: Link): # Auto-fetch - def _queue_auto_fetch(self, entry_id: int, index: int): - """Queue one link of an inbox message for auto-fetch (skipped when the queue is full).""" + def _queue_auto_fetch(self, entry_id: int, index: int, sender: str): + """Queue one link of an inbox message from sender for auto-fetch. When the queue is full, the newest link + of the sender with the most waiting is skipped instead, if that sender has two more than this one; else + this link is.""" + skipped = {"state": "skipped", "error": "too many auto-fetches waiting"} with self._auto_wanted: if len(self._auto_queue) >= AUTO_QUEUE_LIMIT: - self._set_auto(entry_id, index, {"state": "skipped", "error": "too many auto-fetches waiting"}) - return - self._auto_queue.append((entry_id, index)) + displaced = self._auto_queue.pop_busiest(self._auto_queue.count(sender)) + if displaced is None: + self._set_auto(entry_id, index, skipped) + return + self._set_auto(*displaced, skipped) + self._auto_queue.append(sender, (entry_id, index)) self._auto_wanted.notify() def _requeue_auto_fetches(self): @@ -1189,10 +1250,11 @@ def _requeue_auto_fetches(self): for entry in self.inbox.entries(): for index, result in enumerate(entry.get("auto") or []): if isinstance(result, dict) and result.get("state") == "queued": - self._queue_auto_fetch(entry["id"], index) + self._queue_auto_fetch(entry["id"], index, entry["from"]) def _run_auto_fetch(self): - """The auto-fetch worker: one fetch at a time, so quotas are checked against settled usage.""" + """The auto-fetch worker: one fetch at a time, so quotas are checked against settled usage, taking senders + in turn.""" while not self._stop.is_set(): with self._auto_wanted: while not self._auto_queue and not self._stop.is_set(): diff --git a/tests/security/test_lxmf_auto_fetch_fairness.py b/tests/security/test_lxmf_auto_fetch_fairness.py new file mode 100644 index 0000000..3115b29 --- /dev/null +++ b/tests/security/test_lxmf_auto_fetch_fairness.py @@ -0,0 +1,73 @@ +"""LXMF auto-fetch fairness (earlier review, code-read): one worker ran every auto-fetch in arrival order from one +shared 64-entry queue, so one allowlisted sender with many links delayed every other sender's auto-fetches, and +once it had filled the queue their links were skipped. + +Node B runs the lxmf feature on a stand-in network (tests/test_lxmf.py's FeatureTestCase) with two senders in +lxmf_auto_fetch_from. The worker's fetch is replaced by a recorder, so the tests see the order fetches would run +in. A sender's first link must run right after the fetch in progress, not after the other sender's backlog +(round-robin between senders), and a sender with nothing queued must get a place in a full queue, from the sender +holding the most. +""" +import unittest +from unittest import mock + +from rnfs.features import lxmf +from tests import test_lxmf +from tests.test_lxmf import OTHER, SENDER + + +def setUpModule(): + """Silence the node's log.""" + test_lxmf.setUpModule() + + +def tearDownModule(): + """Restore RNS's loglevel.""" + test_lxmf.tearDownModule() + + +class AutoFetchFairnessTest(test_lxmf.FeatureTestCase): + """SENDER and OTHER both in lxmf_auto_fetch_from; fetches recorded, not run.""" + + def setUp(self): + """B's feature, its fetches recorded by sender.""" + super().setUp() + self.feature = self.feature_node(lxmf_auto_fetch_from=[SENDER.hex(), OTHER.hex()]) + self.ran: list[str] = [] + self.feature.auto_fetch = lambda entry_id, index: self.ran.append(self.feature.inbox.get(entry_id)["from"]) + + def test_one_senders_backlog_does_not_delay_another(self): + for number in range(10): + self.feature.received(self.link_message(source=SENDER, number=number)) + self.feature.received(self.link_message(source=OTHER, number=99)) + self.feature.process_auto_fetches() + # Fails until auto-fetches are taken round-robin by sender + self.assertEqual(self.ran.index(OTHER.hex()), 1, + f"OTHER's link waited behind {self.ran.index(OTHER.hex())} of SENDER's: {self.ran}") + self.assertEqual(self.ran.count(SENDER.hex()), 10) + + def test_full_queue_makes_room_for_another_sender(self): + with mock.patch.object(lxmf, "AUTO_QUEUE_LIMIT", 4): + ids = [self.feature.received(self.link_message(source=SENDER, number=n)) for n in range(6)] + other = self.feature.received(self.link_message(source=OTHER, number=99)) + states = [self.feature.inbox.get(i)["auto"][0]["state"] for i in ids] + self.assertEqual(states[4:], ["skipped", "skipped"]) # one sender alone still gets the whole queue + # Fails until a sender with fewer links queued takes a place from the one holding the most + self.assertEqual(self.feature.inbox.get(other)["auto"][0]["state"], "queued", + "a full queue of one sender's links skipped another sender's") + self.assertEqual(states[:4], ["queued", "queued", "queued", "skipped"]) # SENDER's newest gave way + self.feature.process_auto_fetches() + self.assertEqual(sorted(self.ran), sorted([SENDER.hex()] * 3 + [OTHER.hex()])) + + def test_restart_requeues_round_robin(self): + for number in range(3): + self.feature.received(self.link_message(source=SENDER, number=number)) + self.feature.received(self.link_message(source=OTHER, number=99)) + self.feature._auto_queue.clear() + self.feature._requeue_auto_fetches() + self.feature.process_auto_fetches() + self.assertEqual(self.ran[:2], [SENDER.hex(), OTHER.hex()]) + + +if __name__ == "__main__": + unittest.main() From f41e8fb34d2935bfef110915abc46bf6bde5b879 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:39:49 +1030 Subject: [PATCH 050/107] Document the LXMF inbox, hold queue and auto-fetch fairness fixes and recommend lxmf_accept_from --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 13 +++++++++++-- design.md | 40 ++++++++++++++++++++++++++++------------ specification.md | 27 +++++++++++++++++++-------- 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ecc2..c36c256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,23 @@ DHT and names: - A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own client never sends more. +LXMF: +- Senders in `lxmf_accept_from` or `lxmf_auto_fetch_from` and LXMF contacts are trusted. Their inbox messages + are dropped only to make room for each other's, after every other sender's message without pins. With + `lxmf_accept_from` empty (the default), about ten new LXMF identities, which cost nothing, could push every + message not holding pins out of the inbox, including contacts' messages and auto-fetches that had failed. +- New inbox messages from senders not trusted are rate limited node-wide: 32 at once, then one a minute. The + README now recommends setting `lxmf_accept_from`. +- Messages from senders whose identity isn't known yet: at most 8 of the 32 held may claim one sender, a held + message is dropped 30 s after an unanswered path request instead of waiting 2 minutes, a message claiming + a trusted sender takes the place of one held for an unknown sender, and trusted senders get path requests + first. 32 forged messages used to fill the queue, so a real first message from a new sender was dropped. + LXMF has already told the sender such a message was delivered, so it can still be lost; trusted senders + are now protected. +- Auto-fetches take senders in turn, and a sender with few links waiting gets a place in a full queue from + the sender with the most. One sender's backlog used to delay every other sender's auto-fetches, and once + it filled the 64-link queue their links were skipped. + ## 0.1.3 Joining the DHT: diff --git a/README.md b/README.md index 1fb19d0..b97582c 100644 --- a/README.md +++ b/README.md @@ -604,13 +604,20 @@ lxmf_auto_fetch_from = [""] | `lxmf_send_from_hint` | `true` | Links you send carry `from=`, so recipients can fetch straight from your node when the DHT doesn't know it. This tells everyone who reads the message which rnfs node your LXMF address belongs to; `false` leaves it out | | `lxmf_propagation_node` | none | Propagation node (32 hex) for `send --propagate`, for resending direct messages that failed, and for the periodic downloads. Without it none of these happen automatically; a propagation node only heard announcing itself is used only with `--nearest` | | `lxmf_sync_interval` | `3600` | How often waiting messages are downloaded from `lxmf_propagation_node`; 0 never | -| `lxmf_accept_from` | `[]` (everyone) | Only keep messages from these LXMF addresses | +| `lxmf_accept_from` | `[]` (everyone) | Only keep messages from these LXMF addresses (recommended: see below) | | `lxmf_auto_fetch_from` | `[]` | Senders whose links are fetched and pinned automatically | | `lxmf_auto_fetch_quota` | `1073741824` (1 GiB) | Storage each auto-fetch sender may use | The inbox keeps at most 500 messages (4 MiB in all), and at most 50 from any one sender not in `lxmf_auto_fetch_from`. Messages holding auto-fetched pins aren't pushed out by other people's messages. +Senders you've named (in `lxmf_accept_from`, in `lxmf_auto_fetch_from`, or as an LXMF contact with +`rnfs contacts add NICK ADDRESS --kind lxmf`) are trusted. Anyone can make new LXMF addresses for free, so +messages from everyone else can only push out each other's, and at most 32 new ones are kept in a burst, +then one a minute; while a flood lasts, a stranger's message may not be kept. If you only expect links from +people you know, set `lxmf_accept_from` to their addresses: messages from anyone else are then ignored, and +nobody can crowd them out. + **Gateway and API** | Setting | Default | Description | @@ -886,4 +893,6 @@ ery thread's stack. - Sealed files hide content and file name, not size or recipients, and don't prove who sealed them. `get` of a directory leaves the sealed files inside it encrypted. - LXMF: messages over 256 kB are refused, and unsent messages are lost if the daemon restarts. Offline - delivery and downloads need `lxmf_propagation_node` (or `--nearest` each time). + delivery and downloads need `lxmf_propagation_node` (or `--nearest` each time). A message rnfs can't keep + (from a stranger during a flood, or from a sender whose identity can't be found) is lost, though the + sender's app shows it as delivered; senders you trust (see the LXMF settings) are protected from floods. diff --git a/design.md b/design.md index 99d40db..c7ecbe4 100644 --- a/design.md +++ b/design.md @@ -352,16 +352,26 @@ announces (forgotten after 7 days unheard), but uses one automatically only if i the nearest heard is used only for a request with `nearest`, and without a configured node the feature logs once (notice) that downloads and the fallback are off. On a propagation-node link it cancels any response Resource over 1 MB (4 × `DELIVERY_LIMIT_KB`) or of more than one segment and tears the link down, and asks for -at most 64 messages per download. Messages from unknown senders are held (at most 32; further ones dropped, and -none from senders `accepts()` would ignore) while their identity is requested: one path request per sender per -2 minutes, from a node-wide token bucket (burst 3, then one per 10 s). A held message stays in the queue while -it is retried, so it still counts as a duplicate and against the limit. The inbox (`MessageLog`) holds at most -500 records and 4 MiB as JSON, 50 per sender not in `lxmf_auto_fetch_from`, 16 KiB per record (links beyond it -dropped, then text cut), and never evicts a record holding auto-fetched pins for one that holds none; the pin's +at most 64 messages per download. `LxmfFeature.trusted()` is true for senders in `lxmf_accept_from` or +`lxmf_auto_fetch_from` and LXMF contacts (`ContactsFeature.has(address, "lxmf")`, an index kept beside the +address book). Messages from unknown senders are held (`_hold`: at most 32, 8 per claimed sender; further ones +dropped, and none from senders `accepts()` would ignore, but a trusted sender's takes the place of the newest +held for the untrusted sender holding the most) while their identity is requested: one path request per sender +per 2 minutes, from a node-wide token bucket (burst 3, then one per 10 s), trusted senders' held messages +retried first. A held message is dropped 30 s (`HOLD_PATH_WAIT`) after its sender's path request, or its own +arrival if later, with no identity known, and after 2 minutes in any case. A held message stays in the queue +while it is retried, so it still counts as a duplicate and against the limit. The inbox (`MessageLog`) holds at +most 500 records and 4 MiB as JSON, 50 per sender not in `lxmf_auto_fetch_from`, 16 KiB per record (links +beyond it dropped, then text cut), and never evicts a record holding auto-fetched pins for one that holds none; +`add(reserved=)` keeps trusted senders' records from being evicted except for each other's (untrusted ones go +first), so free identities can't push them out, and untrusted senders' new records come from a node-wide token +bucket (`UNTRUSTED_BURST` 32, then one per `UNTRUSTED_INTERVAL` 60 s, on `LxmfFeature.clock`). The pin's "pinned" state is recorded in the same locked section that pins. Auto-fetched `name/` links resolve with `persist=False`; each auto-fetch runs under a `fetcher.observing` observer cancelled when the node stops or the message is removed, and links still "queued" in the inbox are queued again at `start()` -(`_requeue_auto_fetches`, within the 64-entry queue). Links without the field are read from the first +(`_requeue_auto_fetches`, within the 64-entry queue). The queue (`_RoundRobin`) keeps one FIFO per sender and +takes senders in turn; when it is full, a sender with at least two fewer waiting than the busiest takes the +busiest one's newest place (that link is marked skipped). Links without the field are read from the first `MAX_SCAN_CHARS` (16 384) characters of the content. `inbox get` (through the jobs feature) checks every output before starting any (two links with one output are refused), then submits a get job per link with its `from` and `fallback_dht`; once one has started, a later link's failure is reported in its result (`error`) rather @@ -393,7 +403,7 @@ name in the output's directory; the CLI sets it for `rnfs get ` withou | `rnfs-job-` | jobs | one per running job, at most `max_jobs`; `stop()` waits ≤ 2 s | | `rnfs-keepers`, `rnfs-keeper-query` | keepers | pending refreshes (≤ 64 queued), own-name sync, saves, re-asking every `announce_interval`; parallel `/name` queries | | `rnfs-follow` | follow | every check and fetch, one at a time | -| `rnfs-lxmf`, `rnfs-lxmf-fetch` | lxmf | announces, propagation-node downloads, polling outbound states each second; auto-fetch, one at a time (LXMF runs its own job thread) | +| `rnfs-lxmf`, `rnfs-lxmf-fetch` | lxmf | announces, propagation-node downloads, polling outbound states each second; auto-fetch, one at a time, senders in turn (LXMF runs its own job thread) | The API and gateway run one thread per connection, capped (32 and 64). @@ -420,7 +430,7 @@ pending withdrawals); `fetched.json` (targets fetched for the user, for `roots`) table); `name-floor.json` (highest sequence of names resolved for the gateway or auto-fetch, at most 4 × `name_table_size`, saved by the re-announce loop when changed); `invalid.json` (invalid roots); `shared_folder.json`; `jobs.json` (results keep at most 100 warnings plus `warnings_total`; older results are capped at load); `keepers.json` (records re-verified at load); -`follow.json`; `contacts.json`; `lxmf_inbox.json` (500 messages, 4 MiB, 50 per untrusted sender, 16 KiB per +`follow.json`; `contacts.json`; `lxmf_inbox.json` (500 messages, 4 MiB, 50 per non-auto-fetch sender, 16 KiB per record) and `lxmf_outbox.json` (200; unfinished messages are marked failed at a restart), both UTF-8 JSON with non-ASCII characters written as is. Elsewhere: `storage/identity`, `storage/lxmf_identity` (0600), `names/` (name keys), `lxmf/` (LXMF's ratchets, delivered-message IDs, stamp costs), `api_token` (while @@ -567,9 +577,15 @@ Everything in the specification is implemented, with these simplifications and a - **LXMF.** Propagation delivery and downloads aren't covered by the e2e test (it has no propagation node). Stamps a recipient demands are computed by LXMF (CPU, in subprocesses). Messages over 256 kB are refused. The outbound queue is lost at a restart (LXMF doesn't keep it). A message from a sender whose identity - can't be found within 2 minutes is lost (LXMF has already marked it delivered). Links sent carry the node - ID as `from` unless `lxmf_send_from_hint = false`; the default display name (`rnfs node`) doesn't. No - propagation node is used unless configured (or asked for with `--nearest`). + can't be found within 2 minutes (30 s after an unanswered path request) is lost, as is one the hold queue + or the rate limit on untrusted senders has no room for: LXMF has already recorded it as delivered and the + sender has its delivery proof. A steady stream of forged messages can still crowd a first message from an + untrusted sender out of the hold queue, and messages with rnfs links from new identities can use up the + untrusted senders' rate limit; trusted senders (contacts, `lxmf_accept_from`, `lxmf_auto_fetch_from`) are + not held back by either, though forgeries claiming one of them can take its 8 hold places until its + identity is known. One auto-fetch runs at a time, so a long one still delays every sender's next. Links + sent carry the node ID as `from` unless `lxmf_send_from_hint = false`; the default display name + (`rnfs node`) doesn't. No propagation node is used unless configured (or asked for with `--nearest`). - **Packaging.** Release artifacts (`.deb` for amd64/arm64, x86_64 Flatpak, Windows x64 exe, wheel and sdist) are built by `.github/workflows/release.yml`. Only a pushed `vX.Y.Z` tag matching `rnfs/__init__.py`'s `__version__` publishes them, once the whole of `tests.yml` (which release.yml calls) has passed, with diff --git a/specification.md b/specification.md index 1de7246..3f2d0dd 100644 --- a/specification.md +++ b/specification.md @@ -1203,11 +1203,17 @@ unreserved, pct-encoded: as in RFC 3986 decompression is bounded to 1 MB. **Receiving.** +- Senders in `lxmf_accept_from` or `lxmf_auto_fetch_from`, and LXMF contacts (section 11), are *trusted*. + Anyone else can make as many LXMF identities as they like, so the bounds below protect trusted senders' + messages from them. - A message is accepted only once LXMF has validated its signature with the sender's identity. A message from - a sender whose identity isn't known yet is held (at most 32, for up to 2 minutes; when 32 are held, further - ones are dropped, so held ones are not pushed out) while a path request fetches it, then validated. The - source of such a message is unauthenticated until its identity is known, so path requests for held senders - are sent at most once per sender per 2 minutes, and at most 3 at once, then one per 10 seconds node-wide. + a sender whose identity isn't known yet is held while a path request fetches it, then validated: at most + 32, at most 8 claiming one sender, for up to 2 minutes, and only 30 seconds after its path request was sent + (or after it arrived, if later) if no answer has made the identity known. When 32 are held, a further one + is dropped, so held ones are not pushed out, unless it claims a trusted sender: then it takes the place of + the newest message held for the untrusted sender holding the most. The source of such a message is + unauthenticated until its identity is known, so path requests for held senders are sent at most once per + sender per 2 minutes, and at most 3 at once, then one per 10 seconds node-wide, trusted senders first. - If `lxmf_accept_from` is not empty, messages from other senders (except those in `lxmf_auto_fetch_from`) are ignored, and those from unknown senders are not held. Messages without rnfs links are ignored. - The links come from the field if it is valid, else from the first 16 384 characters of the content. A link @@ -1217,11 +1223,16 @@ unreserved, pct-encoded: as in RFC 3986 - At most 500 messages and 4 MiB (as JSON) are kept, and at most 50 from one sender not in `lxmf_auto_fetch_from`. To make room, the oldest message holding no auto-fetched pins (none pinned or queued) is dropped; a message holding pins is not dropped for others' messages: if every kept message holds - pins, a new message without any is not kept (and one with pins pushes out the oldest). Each message's - record is at most 16 KiB as JSON: links beyond that are dropped (the first is always kept), then text is - cut. + pins, a new message without any is not kept (and one with pins pushes out the oldest). A trusted sender's + message is dropped only to make room for another trusted sender's, and only once no untrusted sender's + message without pins is left; a new message from an untrusted sender that would need one dropped is not + kept. New messages from untrusted senders are also rate limited node-wide: 32 at once, then one per minute; + others are not kept. Each message's record is at most 16 KiB as JSON: links beyond that are dropped (the + first is always kept), then text is cut. - **Auto-fetch.** Links from a sender in `lxmf_auto_fetch_from` are fetched completely and pinned with source - `lxmf:`, one fetch at a time (at most 64 waiting; further links are skipped). Auto-fetches + `lxmf:`, one fetch at a time, taking senders in turn. At most 64 links wait: when that many + do, a new link is skipped, unless its sender has at least two fewer waiting than the sender with the most, + whose newest waiting link is skipped instead. Auto-fetches still waiting when the node stops are queued again, oldest first, at the next start; stopping the node or removing the message stops a fetch in progress. The fetch is refused if the target's verified size plus what that source already pins would exceed `lxmf_auto_fetch_quota`, or if the blocks of its DAG would (as for From e2b56869fd15dc89e6167a3592888c8515d88f8d Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:42:53 +1030 Subject: [PATCH 051/107] Install the release sdist in install.sh, checked against SHA256SUMS and, where gh can, its signed provenance --- CHANGELOG.md | 7 + README.md | 17 +- install.sh | 100 +++++++++- .../test_l15_install_release_check.py | 175 ++++++++++++++++++ 4 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 tests/security/test_l15_install_release_check.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a68b72..8854ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,13 @@ Platform and packaging: An existing rnfs dir or identity file that lets others in is tightened (with a notice), the daemon refuses an rnfs dir owned by another account, and every command refuses a token file others can read, as on POSIX. A `--dir` outside `%USERPROFILE%` gets a warning. Not yet tested on Windows itself. +- `install.sh` installs the latest release from its sdist (`rnfs-X.Y.Z.tar.gz`, a release file) instead of + GitHub's archive of the tag, which neither `SHA256SUMS` nor the build provenance covers and which a moved tag + would change unnoticed. The sdist must match the release's `SHA256SUMS`; where the GitHub CLI (2.49 or + newer) is installed and logged in, its signed provenance is checked too (`gh attestation verify`, signed by + the release workflow), and a mismatch or failed check stops the install. Without `gh` it warns that only + the checksum was checked; `RNFS_REQUIRE_ATTESTATION=1` makes that an error. The README's "Checking a + download" shows `gh attestation verify`. - The identity file's permission fix no longer follows a symlink: a symlink at the identity path is left as it is, with a warning, and the file is changed through a descriptor opened with `O_NOFOLLOW`. diff --git a/README.md b/README.md index dd746ce..06aedc6 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,15 @@ shasum -a 256 rnfs-0.1.3-py3-none-any.whl # macOS: compare with the line Get-FileHash .\rnfs-0.1.3-windows-x64.exe # Windows: compare with the line in SHA256SUMS ``` +A matching checksum shows the download is whole, not who made it: anyone able to replace a release's files +could replace `SHA256SUMS` too. Every file also has signed build provenance, which shows it was built by this +repository's release workflow. Check it with the [GitHub CLI](https://cli.github.com/) (2.49 or newer, +logged in with `gh auth login`): + +```sh +gh attestation verify rnfs_0.1.3_amd64.deb --repo afit21/rnfs +``` + ### Other ways to install The install script installs the latest release; pipx from GitHub and a source checkout install the latest @@ -255,8 +264,11 @@ curl -fsSL https://raw.githubusercontent.com/afit21/rnfs/main/install.sh | sh ``` This installs the latest rnfs release, with Reticulum and LXMF, into its own virtual environment at -`~/.local/share/rnfs/venv`. Dependencies are installed at the versions pinned in the source's -`packaging/requirements.lock`, with their sha256 hashes checked. It links the `rnfs` command into +`~/.local/share/rnfs/venv`. It installs the release's source archive (`rnfs-X.Y.Z.tar.gz`) only if it matches +the release's `SHA256SUMS` and, if the [GitHub CLI](https://cli.github.com/) (2.49 or newer) is installed and +logged in, its signed build provenance (`gh attestation verify`). Without `gh` it warns that only the checksum +was checked; set `RNFS_REQUIRE_ATTESTATION=1` to make it stop instead. Dependencies are installed at the +versions pinned in the source's `packaging/requirements.lock`, with their sha256 hashes checked. It links the `rnfs` command into `~/.local/bin` and tells you if that folder isn't on your PATH. Options go after `sh -s --`: ```sh @@ -271,6 +283,7 @@ curl -fsSL https://raw.githubusercontent.com/afit21/rnfs/main/install.sh | sh -s the service, it restarts. - **Main branch:** `--main` installs the latest code from the main branch, which may be newer than the latest release. Give it again each time you update, or the next update goes back to the latest release. + The branch has no checksums or provenance to check. - **Service:** `--service` installs `~/.config/systemd/user/rnfs.service` and starts it. To see its logs: `journalctl --user -u rnfs -f`. To keep it running while you're logged out: `loginctl enable-linger $USER`. diff --git a/install.sh b/install.sh index 11ad805..d2504b5 100755 --- a/install.sh +++ b/install.sh @@ -12,6 +12,7 @@ # --service also install the systemd user unit and start it (system mode: install it for all users, # each then runs `systemctl --user enable --now rnfs`) # --main install the main branch, which may be newer than the latest release, instead of the release +# (the branch archive has no checksum or provenance to check) # --update re-install from the latest release, or the main branch with --main (running the installer # again does the same) # --uninstall remove the venv, the command and the unit (keeps your data in ~/.rnfs) @@ -25,6 +26,15 @@ # RNFS_ALLOW_SOURCE_BUILDS=1 build dependencies PyPI has no wheel of for this platform (e.g. # cffi on 32-bit ARM) from their pinned, hash-checked source; the tools that build # them are then fetched unpinned, and a C compiler and Python headers are needed +# RNFS_REQUIRE_ATTESTATION=1 fail unless the release's signed provenance could be checked (needs +# the GitHub CLI, gh 2.49 or newer, logged in) +# +# The latest release is installed from its source archive (rnfs-X.Y.Z.tar.gz, a release file, not the archive +# GitHub makes of the tag), which must match the release's SHA256SUMS. Where the GitHub CLI (gh 2.49 or newer) +# is installed and logged in, its signed build provenance is checked too (gh attestation verify, made by +# release.yml for this repository), and a failed check stops the install. Without gh the install goes ahead with +# a warning: SHA256SUMS shows the download is whole, but whoever could replace the release's files could +# replace it too. # # Dependencies (rns, lxmf, cryptography, ...) are installed as wheels at the versions and sha256 hashes # pinned in the source's packaging/requirements.lock (pip --require-hashes --only-binary=:all:), and rnfs @@ -36,9 +46,12 @@ set -eu REPO_URL="https://github.com/afit21/rnfs" -# Without RNFS_SOURCE, this script (as served from the main branch) installs the latest release's source tree, -# which may be older than the script. It must keep working with the layout of every release from 0.1.1 on: -# packaging/requirements.lock and packaging/build-backend.lock, which fetch_source looks for. +REPO=${REPO_URL#https://github.com/} # owner/name, for gh +RELEASE_TAG="" # the release being installed (set by choose_source), whose files fetch_release checks +# Without RNFS_SOURCE, this script (as served from the main branch) installs the latest release's sdist, which +# may be older than the script. It must keep working with every release from 0.1.3 on: release files +# rnfs-X.Y.Z.tar.gz and SHA256SUMS (with build provenance), and in the sdist packaging/requirements.lock and +# packaging/build-backend.lock, which fetch_source looks for. SOURCE=${RNFS_SOURCE:-} # empty: chosen by choose_source once Python is found MAIN=no PYTHON=${RNFS_PYTHON:-} @@ -279,19 +292,93 @@ print(tag) EOF } -choose_source() { # without RNFS_SOURCE: the latest release's source archive, or the main branch's with --main +choose_source() { # without RNFS_SOURCE: the latest release's sdist (checked by fetch_release), or the main branch's archive with --main [ -z "$SOURCE" ] || return 0 if [ "$MAIN" = yes ]; then SOURCE=$REPO_URL/archive/refs/heads/main.zip - say "Installing the main branch, which may be newer than the latest release" + say "Installing the main branch, which may be newer than the latest release (it has no checksums to check)" else tag=$(latest_release) || die "couldn't find rnfs's latest release on GitHub. Check your connection, or install the main branch instead with --main." - SOURCE=$REPO_URL/archive/refs/tags/$tag.zip + # The sdist the release workflow built, checksummed and attested, not GitHub's archive of the tag + # (archive/refs/tags/...), which nothing covers and which a moved tag would change + RELEASE_TAG=$tag + SOURCE=$REPO_URL/releases/download/$tag/rnfs-${tag#v}.tar.gz say "Installing rnfs $tag, the latest release" fi } +download() { # download URL FILE: save URL's content as FILE + "$PYTHON" -I - "$1" "$2" <<'EOF' +import shutil, sys, urllib.request + +url, path = sys.argv[1:3] +try: + with urllib.request.urlopen(url, timeout=120) as response, open(path, "wb") as f: + shutil.copyfileobj(response, f) +except OSError as e: + sys.exit(f"couldn't download {url}: {e}") +EOF +} + +verify_release() { # verify_release FILE SUMS: stop unless FILE matches its line in SUMS and, where gh can tell, its provenance + "$PYTHON" -I - "$1" "$2" <<'EOF' || die "$(basename -- "$1") doesn't match the release's SHA256SUMS (above), so it wasn't installed" +import hashlib, os, sys + +path, sums = sys.argv[1:3] +name = os.path.basename(path) +with open(sums, encoding="utf-8", errors="replace") as f: + lines = [line.split() for line in f] +# sha256sum's format: the hex digest, then the name (after "*" in binary mode) +expected = [words[0].lower() for words in lines if len(words) == 2 and words[1].lstrip("*") == name] +if len(expected) != 1: + sys.exit(f"SHA256SUMS has {len(expected)} lines for {name}, not one") +digest = hashlib.sha256() +with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + digest.update(chunk) +if digest.hexdigest() != expected[0]: + sys.exit(f"{name} has sha256 {digest.hexdigest()}, but SHA256SUMS says {expected[0]}") +EOF + # SHA256SUMS is a release file like any other: whoever can replace the sdist can replace it too. The signed + # provenance can't be made outside this repository's release workflow + if gh_can_verify; then + # Older gh versions can only name the repository, not the workflow + signer="" + if gh attestation verify --help 2>&1 | grep -q -e '--signer-workflow'; then + signer="--signer-workflow=$REPO/.github/workflows/release.yml" + fi + # shellcheck disable=SC2086 # signer is one word or nothing + if ! gh attestation verify "$1" --repo "$REPO" $signer >"$1.attestation" 2>&1; then + cat "$1.attestation" >&2 + die "gh attestation verify found no valid signed provenance for $(basename -- "$1") (above), so it wasn't +installed: it must have been built by $REPO's release workflow. Please report this at $REPO_URL/issues." + fi + say "Checked $(basename -- "$1") against SHA256SUMS and its signed build provenance (gh attestation verify)" + elif [ "${RNFS_REQUIRE_ATTESTATION:-}" = 1 ]; then + die "RNFS_REQUIRE_ATTESTATION=1, but there's no GitHub CLI (gh 2.49 or newer, logged in with gh auth login) to +check the release's signed provenance with" + else + warn "checked $(basename -- "$1") against the release's SHA256SUMS only. That shows the download is whole, +not that the release workflow built it: with the GitHub CLI (gh 2.49 or newer, logged in with gh auth login) its +signed provenance is checked too. Set RNFS_REQUIRE_ATTESTATION=1 to refuse to install without that check." + fi +} + +gh_can_verify() { # the GitHub CLI is here, logged in, and new enough to have gh attestation (2.49) + command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 +} + +fetch_release() { # fetch_release WORKDIR: download the release's sdist and SHA256SUMS, check them, and install that file + mkdir "$1/release" + sdist=$1/release/rnfs-${RELEASE_TAG#v}.tar.gz + download "$REPO_URL/releases/download/$RELEASE_TAG/SHA256SUMS" "$1/release/SHA256SUMS" || + die "couldn't download the SHA256SUMS of release $RELEASE_TAG" + download "$SOURCE" "$sdist" || die "couldn't download $SOURCE" + verify_release "$sdist" "$1/release/SHA256SUMS" + SOURCE=$sdist +} + # The source: a local tree, or an archive downloaded and unpacked here (by Python, so no curl/unzip needed), # so its packaging/requirements.lock and packaging/build-backend.lock can be used. A wheel or other pip requirement goes to pip as it is. @@ -436,6 +523,7 @@ do_install() { wheels=$(mktemp -d "${TMPDIR:-/tmp}/rnfs-install.XXXXXX") trap 'rm -rf "$wheels"' EXIT INT TERM say "Fetching and building rnfs from $SOURCE" + if [ -n "$RELEASE_TAG" ]; then fetch_release "$wheels"; fi fetch_source "$wheels" build_wheel "$wheels" wheel="" diff --git a/tests/security/test_l15_install_release_check.py b/tests/security/test_l15_install_release_check.py new file mode 100644 index 0000000..3d6b2d3 --- /dev/null +++ b/tests/security/test_l15_install_release_check.py @@ -0,0 +1,175 @@ +"""L15 (fix): install.sh installed GitHub's auto-generated archive of the release tag, which neither SHA256SUMS nor the +signed provenance covers and which a moved tag changes. Now it installs the release's sdist, checked against the +release's SHA256SUMS and, where the GitHub CLI can, its signed provenance (gh attestation verify), failing closed. + +install.sh's download, verify_release and fetch_release functions are run with sh against a fake release (file:// +URLs) and a fake gh on a PATH holding only the tools they need, so the real network and any real gh are never +used. Whether the real gh accepts the real release's attestation is not tested here. +""" +import hashlib +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from tests.test_packaging import INSTALL_SH, read, shell_function + +PRELUDE = r""" +say() { printf '%s\n' "$*"; } +warn() { printf 'rnfs installer: warning: %s\n' "$*" >&2; } +die() { printf 'rnfs installer: %s\n' "$*" >&2; exit 1; } +REPO=afit21/rnfs +""" +# A gh that logs its arguments and answers as FAKE_GH says: "old" (no attestation command), "logged-out", "fail" +# (verification fails) or "ok" +FAKE_GH = r"""#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_GH_LOG" +case "$1 $2" in + "auth status") [ "$FAKE_GH" != logged-out ] ;; + "attestation verify") + [ "$FAKE_GH" != old ] || { echo 'unknown command "attestation" for "gh"' >&2; exit 1; } + case " $* " in *" --help "*) echo " --signer-workflow string"; exit 0 ;; esac + [ "$FAKE_GH" = ok ] || { echo "Error: no attestations found" >&2; exit 1; } ;; + *) exit 2 ;; +esac +""" +TOOLS = ("basename", "cat", "mkdir", "grep", "printf") +SDIST = b"rnfs-9.9.9 source archive" + + +@unittest.skipUnless(shutil.which("sh"), "needs a POSIX shell") +class ReleaseCheckTest(unittest.TestCase): + """A fake release v9.9.9 (its sdist and SHA256SUMS) under a file:// REPO_URL.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.release = os.path.join(self.tmp.name, "repo", "releases", "download", "v9.9.9") + os.makedirs(self.release) + self.work = os.path.join(self.tmp.name, "work") + os.mkdir(self.work) + self.publish(SDIST, SDIST) + # Only the tools the functions use: no real gh, whatever this machine has + self.bin = os.path.join(self.tmp.name, "bin") + os.mkdir(self.bin) + for tool in TOOLS: + found = shutil.which(tool) + if found: + os.symlink(found, os.path.join(self.bin, tool)) + self.gh_log = os.path.join(self.tmp.name, "gh.log") + + def publish(self, sdist: bytes, summed: bytes, name: str = "rnfs-9.9.9.tar.gz"): + """Put sdist in the release, with a SHA256SUMS line for summed under name (and one for another file).""" + with open(os.path.join(self.release, "rnfs-9.9.9.tar.gz"), "wb") as f: + f.write(sdist) + with open(os.path.join(self.release, "SHA256SUMS"), "w") as f: + f.write(f"{hashlib.sha256(b'other').hexdigest()} rnfs-9.9.9-py3-none-any.whl\n") + f.write(f"{hashlib.sha256(summed).hexdigest()} {name}\n") + + def install_gh(self, behaviour: str): + """Put the fake gh on the PATH, answering as behaviour.""" + gh = os.path.join(self.bin, "gh") + with open(gh, "w") as f: + f.write(FAKE_GH) + os.chmod(gh, 0o755) + self.gh = behaviour + + def fetch(self, **env) -> subprocess.CompletedProcess: + """Run fetch_release for v9.9.9 as do_install would; prints the SOURCE it leaves.""" + repo_url = "file://" + os.path.join(self.tmp.name, "repo") + script = "\n".join([PRELUDE, shell_function("download"), shell_function("verify_release"), + shell_function("gh_can_verify"), shell_function("fetch_release"), + 'fetch_release "$1"', 'printf "SOURCE=%s\\n" "$SOURCE"']) + environment = {"PATH": self.bin, "PYTHON": sys.executable, "REPO_URL": repo_url, "RELEASE_TAG": "v9.9.9", + "SOURCE": repo_url + "/releases/download/v9.9.9/rnfs-9.9.9.tar.gz", + "FAKE_GH": getattr(self, "gh", ""), "FAKE_GH_LOG": self.gh_log, **env} + return subprocess.run([shutil.which("sh"), "-c", script, "sh", self.work], capture_output=True, text=True, + env=environment) + + def assert_installs_checked_file(self, result: subprocess.CompletedProcess): + """fetch_release succeeded and left SOURCE at the downloaded, checked sdist.""" + self.assertEqual(result.returncode, 0, result.stderr) + local = os.path.join(self.work, "release", "rnfs-9.9.9.tar.gz") + self.assertIn(f"SOURCE={local}\n", result.stdout) + with open(local, "rb") as f: + self.assertEqual(f.read(), SDIST) + + def assert_refused(self, result: subprocess.CompletedProcess, message: str): + """fetch_release stopped the install with message.""" + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn(message, result.stderr) + self.assertNotIn("SOURCE=", result.stdout) + + def gh_calls(self) -> list[str]: + """The fake gh's command lines.""" + if not os.path.exists(self.gh_log): + return [] + with open(self.gh_log) as f: + return f.read().splitlines() + + def test_matching_sdist_without_gh_installs_with_a_warning(self): + result = self.fetch() + self.assert_installs_checked_file(result) + self.assertIn("against the release's SHA256SUMS only", result.stderr) + + def test_without_gh_attestation_can_be_required(self): + self.assert_refused(self.fetch(RNFS_REQUIRE_ATTESTATION="1"), "RNFS_REQUIRE_ATTESTATION=1, but") + + def test_replaced_sdist_is_refused(self): + self.publish(b"backdoored", SDIST) + self.assert_refused(self.fetch(), "doesn't match the release's SHA256SUMS") + self.install_gh("ok") # a good attestation answer doesn't matter: the checksum is checked first + self.assert_refused(self.fetch(), "has sha256") + + def test_sdist_missing_from_sha256sums_is_refused(self): + self.publish(SDIST, SDIST, name="rnfs-9.9.8.tar.gz") + self.assert_refused(self.fetch(), "SHA256SUMS has 0 lines for rnfs-9.9.9.tar.gz") + + def test_missing_sha256sums_is_refused(self): + os.remove(os.path.join(self.release, "SHA256SUMS")) + self.assert_refused(self.fetch(), "couldn't download the SHA256SUMS of release v9.9.9") + + def test_attestation_checked_with_gh(self): + self.install_gh("ok") + result = self.fetch(RNFS_REQUIRE_ATTESTATION="1") + self.assert_installs_checked_file(result) + self.assertIn("signed build provenance (gh attestation verify)", result.stdout) + local = os.path.join(self.work, "release", "rnfs-9.9.9.tar.gz") + self.assertIn(f"attestation verify {local} --repo afit21/rnfs " + f"--signer-workflow=afit21/rnfs/.github/workflows/release.yml", self.gh_calls()) + + def test_failed_attestation_is_refused(self): + self.install_gh("fail") + result = self.fetch() + self.assert_refused(result, "no valid signed provenance") + self.assertIn("no attestations found", result.stderr) # gh's own explanation is shown + + def test_gh_that_cant_verify_counts_as_absent(self): + # Ubuntu 24.04's gh 2.45 has no attestation command; a gh not logged in can't query attestations + for behaviour in ("old", "logged-out"): + with self.subTest(behaviour): + self.install_gh(behaviour) + self.assert_installs_checked_file(self.fetch()) + self.assertFalse([call for call in self.gh_calls() if " --repo " in call]) + self.assert_refused(self.fetch(RNFS_REQUIRE_ATTESTATION="1"), "RNFS_REQUIRE_ATTESTATION=1") + + +class DefaultSourceTest(unittest.TestCase): + """What install.sh installs by default.""" + + def test_installs_the_release_sdist_not_the_tag_archive(self): + text = read(INSTALL_SH) + self.assertNotIn("archive/refs/tags", re.sub(r"(?m)^\s*#.*$", "", text)) + choose = shell_function("choose_source") + self.assertIn("RELEASE_TAG=$tag", choose) + self.assertIn("SOURCE=$REPO_URL/releases/download/$tag/rnfs-${tag#v}.tar.gz", choose) + install = shell_function("do_install") + self.assertLess(install.index('fetch_release "$wheels"'), install.index('fetch_source "$wheels"')) + self.assertIn('verify_release "$sdist"', shell_function("fetch_release")) + + +if __name__ == "__main__": + unittest.main() From cb7f8ecfb38e78454c1ba68c89a38e21a750f45c Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:42:53 +1030 Subject: [PATCH 052/107] Expect install.sh to install the release sdist in the packaging tests --- tests/test_packaging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index ab5d2f6..80d8392 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -60,10 +60,10 @@ def test_leaves_the_callers_directory(self): self.assertLess(text.index("\ncd /\n"), text.index("\ndo_install() {")) def test_installs_the_latest_release_by_default(self): - """Without RNFS_SOURCE it installs the latest release's tag, or the main branch with --main.""" + """Without RNFS_SOURCE it installs the latest release's sdist, or the main branch with --main.""" choose = shell_function("choose_source") self.assertEqual(choose.splitlines()[1].strip(), '[ -z "$SOURCE" ] || return 0') # RNFS_SOURCE wins - self.assertIn("SOURCE=$REPO_URL/archive/refs/tags/$tag.zip", choose) + self.assertIn("SOURCE=$REPO_URL/releases/download/$tag/rnfs-${tag#v}.tar.gz", choose) self.assertIn("SOURCE=$REPO_URL/archive/refs/heads/main.zip", choose) self.assertIn('"/releases/latest"', shell_function("latest_release")) self.assertIn(" choose_source\n", shell_function("do_install")) From 1f3cb70db1bd153847dc40ace28bd79841787c11 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:03 +1030 Subject: [PATCH 053/107] Mount the checkout read-only in the release's Debian smoke test, pin its image by digest and run the test as nobody --- .github/workflows/release.yml | 19 +++++-- CHANGELOG.md | 3 + tests/security/test_l16_release_container.py | 58 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 tests/security/test_l16_release_container.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 850cadf..93ab932 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,8 @@ on: permissions: contents: read -# Actions are pinned to commit SHAs and the Flatpak container to a digest (the comments say which tag); -# update both together. Values from expressions reach run: scripts only through env:, never spliced in. +# Actions are pinned to commit SHAs and the Flatpak and Debian containers to digests (the comments say which +# tag); update them together. Values from expressions reach run: scripts only through env:, never spliced in. # Build tools come from packaging/build-tools.lock and the build backend from it (or build-backend.lock) with # --require-hashes, and builds run without build isolation, so nothing unpinned is fetched for a build. jobs: @@ -95,14 +95,23 @@ jobs: rnfs --version python3 packaging/smoke_test.py /usr/bin/rnfs sudo apt-get remove -y rnfs + # The .deb is uploaded, checksummed and attested next, so the container must not be able to change it: + # the checkout is mounted read-only (the .deb is copied out to be installed), the image is pinned by + # digest (debian:12, the multi-arch index as of 2026-10-14), and only apt-get runs as root; the smoke + # test runs as nobody - name: Install and smoke test (Debian 12, Python 3.11) if: matrix.arch == 'amd64' run: | - docker run --rm -v "$PWD:/w" -w /w debian:12 sh -ec ' + docker run --rm --security-opt no-new-privileges -v "$PWD:/w:ro" -w /w \ + debian:12@sha256:2c037a04925515fdd6ea85ea14a682d0e79931f5e9f5d07b6dbfc6ba12f9e858 sh -ec ' + cp dist/rnfs_*_amd64.deb /tmp/ apt-get update -qq - apt-get install -y -qq ./dist/rnfs_*_amd64.deb >/dev/null + apt-get install -y -qq /tmp/rnfs_*_amd64.deb >/dev/null python3 --version - python3 packaging/smoke_test.py /usr/bin/rnfs' + mkdir /tmp/smoke + chown nobody:nogroup /tmp/smoke + HOME=/tmp/smoke TMPDIR=/tmp/smoke setpriv --reuid=nobody --regid=nogroup --clear-groups \ + python3 packaging/smoke_test.py /usr/bin/rnfs' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-deb-${{ matrix.arch }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8854ac4..4217284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ Platform and packaging: the release workflow), and a mismatch or failed check stops the install. Without `gh` it warns that only the checksum was checked; `RNFS_REQUIRE_ATTESTATION=1` makes that an error. The README's "Checking a download" shows `gh attestation verify`. +- The release workflow's Debian 12 smoke test mounts the checkout read-only (it installs a copy of the + `.deb`), runs an image pinned by digest with `no-new-privileges`, and runs the smoke test as `nobody`. The + container could change the `.deb` just before it was uploaded, checksummed and attested. - The identity file's permission fix no longer follows a symlink: a symlink at the identity path is left as it is, with a warning, and the file is changed through a descriptor opened with `O_NOFOLLOW`. diff --git a/tests/security/test_l16_release_container.py b/tests/security/test_l16_release_container.py new file mode 100644 index 0000000..98ccf0b --- /dev/null +++ b/tests/security/test_l16_release_container.py @@ -0,0 +1,58 @@ +"""L16 (fix): the release workflow's Debian 12 smoke test ran `docker run -v "$PWD:/w" debian:12` (a read-write mount, +an image not pinned by digest, run as root) right before the .deb it could change was uploaded, checksummed and +attested. Now the checkout is mounted read-only, the image is pinned by digest and the smoke test runs as nobody. + +Static checks of .github/workflows/*.yml, as tests/test_packaging.py makes. The workflow step itself was run +locally with Docker against a .deb built by packaging/build-deb.sh (see the report); CI didn't run it. +""" +import os +import re +import unittest + +from tests.test_packaging import WORKFLOWS, read + +DIGEST = re.compile(r"@sha256:[0-9a-f]{64}\b") + + +def docker_runs(text: str) -> list[str]: + """Every `docker run` command in a workflow, its continuation lines joined.""" + joined = re.sub(r"\\\n\s*", " ", text) + return [line.strip() for line in joined.splitlines() if "docker run" in line and not line.lstrip().startswith("#")] + + +class ReleaseContainerTest(unittest.TestCase): + """Containers the workflows run can't change what they build.""" + + def test_docker_runs_are_read_only_and_pinned(self): + found = 0 + for name in os.listdir(WORKFLOWS): + for command in docker_runs(read(os.path.join(WORKFLOWS, name))): + found += 1 + with self.subTest(name=name, command=command): + mounts = re.findall(r"(?:-v|--volume)[ =]\"?([^\" ]+)", command) + self.assertTrue(mounts) + for mount in mounts: + self.assertRegex(mount, r":ro$", "mounted writable") + self.assertNotIn("--mount", command) + self.assertRegex(command, DIGEST, "image not pinned by digest") + self.assertIn("--security-opt no-new-privileges", command) + self.assertGreaterEqual(found, 1) + + def test_debian_smoke_test_runs_as_nobody(self): + text = read(os.path.join(WORKFLOWS, "release.yml")) + step = text[text.index("- name: Install and smoke test (Debian 12"):] + step = step[:step.index("- uses:")] + self.assertIn("cp dist/rnfs_*_amd64.deb /tmp/", step) # installed from a copy: the mount is read-only + self.assertIn("apt-get install -y -qq /tmp/rnfs_*_amd64.deb", step) + self.assertRegex(step, r"setpriv --reuid=nobody --regid=nogroup --clear-groups \\\s+python3 " + r"packaging/smoke_test.py") + + def test_containers_are_pinned_by_digest(self): + for name in os.listdir(WORKFLOWS): + for image in re.findall(r"(?m)^\s*image:\s*(\S+)", read(os.path.join(WORKFLOWS, name))): + with self.subTest(name=name, image=image): + self.assertRegex(image, DIGEST) + + +if __name__ == "__main__": + unittest.main() From cbf257e76bb4550c7579f58a8e8c22fd621a67a6 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:03 +1030 Subject: [PATCH 054/107] Run the release job in a release environment and filter the systemd unit's system calls --- .github/workflows/release.yml | 3 ++ CHANGELOG.md | 4 ++ contrib/rnfs.service | 5 ++ install.sh | 5 ++ tests/security/test_i2_packaging_hardening.py | 50 +++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 tests/security/test_i2_packaging_hardening.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93ab932..060ce92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,6 +224,9 @@ jobs: if: github.event_name == 'push' && github.ref_type == 'tag' needs: [tests, version, python, deb, flatpak, windows] runs-on: ubuntu-latest + # Protection rules (required reviewers, which tags may deploy) are set on this environment in the + # repository's settings, so publishing can be gated beyond whoever can push a v* tag + environment: release permissions: contents: write # create the release id-token: write # sign the provenance attestation (Sigstore certificate for this workflow run) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4217284..cb8d57e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ Platform and packaging: - The release workflow's Debian 12 smoke test mounts the checkout read-only (it installs a copy of the `.deb`), runs an image pinned by digest with `no-new-privileges`, and runs the smoke test as `nobody`. The container could change the `.deb` just before it was uploaded, checksummed and attested. +- The release job runs in a `release` environment, so publishing can be gated by that environment's + protection rules (set in the repository's settings). +- The systemd user unit (`contrib/rnfs.service` and the one `install.sh` writes) adds + `SystemCallFilter=@system-service` and `SystemCallArchitectures=native`. - The identity file's permission fix no longer follows a symlink: a symlink at the identity path is left as it is, with a warning, and the file is changed through a descriptor opened with `O_NOFOLLOW`. diff --git a/contrib/rnfs.service b/contrib/rnfs.service index 83b5d81..7e9776f 100644 --- a/contrib/rnfs.service +++ b/contrib/rnfs.service @@ -34,6 +34,11 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK LockPersonality=yes RestrictRealtime=yes RestrictSUIDSGID=yes +# Only the system calls ordinary services make, for this machine's own architecture (seccomp, which works in a +# user unit because NoNewPrivileges is set). A PipeInterface's program is held to the same. MemoryDenyWriteExecute +# is left out: libffi closures, which cffi (under cryptography) can use, may need writable executable memory. +SystemCallFilter=@system-service +SystemCallArchitectures=native [Install] WantedBy=default.target diff --git a/install.sh b/install.sh index d2504b5..5372b74 100755 --- a/install.sh +++ b/install.sh @@ -268,6 +268,11 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK LockPersonality=yes RestrictRealtime=yes RestrictSUIDSGID=yes +# Only the system calls ordinary services make, for this machine's own architecture (seccomp, which works in a +# user unit because NoNewPrivileges is set). A PipeInterface's program is held to the same. MemoryDenyWriteExecute +# is left out: libffi closures, which cffi (under cryptography) can use, may need writable executable memory. +SystemCallFilter=@system-service +SystemCallArchitectures=native [Install] WantedBy=default.target diff --git a/tests/security/test_i2_packaging_hardening.py b/tests/security/test_i2_packaging_hardening.py new file mode 100644 index 0000000..7e4d1e0 --- /dev/null +++ b/tests/security/test_i2_packaging_hardening.py @@ -0,0 +1,50 @@ +"""I2 (fix), packaging and CI: the release job named no environment, so no protection rules could gate publishing; the +systemd user unit had no system call filter; and the README's "Checking a download" didn't mention the signed +provenance. + +Static checks of the workflow, both systemd units (contrib/rnfs.service and the one install.sh writes) and the +README. The environment's protection rules live in the repository's settings, which no test here can see. The +unit's filter was tried locally with systemd-run (see the report). +""" +import os +import re +import unittest + +from tests.test_packaging import REPO, UNIT, WORKFLOWS, read, shell_function + +FILTER = ("SystemCallFilter=@system-service", "SystemCallArchitectures=native") + + +class ReleaseEnvironmentTest(unittest.TestCase): + + def test_release_job_has_an_environment(self): + release = read(os.path.join(WORKFLOWS, "release.yml")) + job = re.search(r"(?m)^ release:\n((?: .*\n|\s*\n)*)", release) + self.assertTrue(job) + self.assertRegex(job.group(1), r"(?m)^ environment: release$") + + +class UnitHardeningTest(unittest.TestCase): + + def test_units_filter_system_calls(self): + for name, text in (("contrib/rnfs.service", read(UNIT)), ("install.sh", shell_function("write_unit"))): + lines = text.splitlines() + with self.subTest(name): + for setting in FILTER: + self.assertIn(setting, lines) + self.assertIn("NoNewPrivileges=yes", lines) # what lets a user unit use the filter + self.assertFalse([line for line in lines if line.startswith("MemoryDenyWriteExecute")]) + + +class ReadmeTest(unittest.TestCase): + + def test_checking_a_download_mentions_attestations(self): + text = read(os.path.join(REPO, "README.md")) + section = text[text.index("### Checking a download"):] + section = section[:section.index("\n### ", 1)] + self.assertIn("gh attestation verify", section) + self.assertIn("--repo afit21/rnfs", section) + + +if __name__ == "__main__": + unittest.main() From 719c1bc43bdacc7ed7a4084d5c0953a3e091dcc8 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:11 +1030 Subject: [PATCH 055/107] Fetch every accepted remote pin: deregister the worker when its queue is empty, refuse when its thread can't start, and resume waiting pins after a restart --- rnfs/node.py | 142 ++++++++++++++--- tests/security/test_new3_remote_pin_wakeup.py | 147 ++++++++++++++++++ .../security/test_new6_remote_pins_restart.py | 144 +++++++++++++++++ 3 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 tests/security/test_new3_remote_pin_wakeup.py create mode 100644 tests/security/test_new6_remote_pins_restart.py diff --git a/rnfs/node.py b/rnfs/node.py index c998b61..c7b6732 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -50,6 +50,7 @@ from rnfs.publisher import Publisher PROVIDERS_FILE = "providers.json" +REMOTE_PINS_FILE = "remote_pins.json" # remote pins accepted and not yet fetched: [[source, cid], ...] in order FETCHED_FILE = "fetched.json" # cached targets fetched for the user (not the gateway): the roots strategy's SAVE_INTERVAL = 300.0 # seconds between saves of provider records STATE_SAVE_INTERVAL = 30.0 # seconds between saves of cache records and invalid roots, when they changed @@ -305,6 +306,9 @@ def _setup(self, config: Config, node_id: bytes, client, slow: bool, names_netwo self._remote_reserved: dict[str, dict[Cid, None]] = {} self._remote_released: set[tuple[str, Cid]] = set() # remote pins released while still being fetched self._remote_workers: set[str] = set() # remote sources with a thread fetching their pins + self._remote_pins_path = os.path.join(config.state_dir, REMOTE_PINS_FILE) # those waiting, for a restart + self._remote_pins_dirty = False # changed since last saved (by maintenance, and at stop) + self._remote_pins_save_lock = threading.Lock() # one save at a time, so an older snapshot never lands last self._refresh_wanted = threading.Event() self.stop_event = threading.Event() self.joined = threading.Event() @@ -331,6 +335,7 @@ def _start_threads(self): thread = threading.Thread(target=target, name=name, daemon=True) thread.start() self.threads.append(thread) + self._resume_remote_pins() for feature in self.features.values(): try: feature.start() @@ -361,6 +366,7 @@ def stop(self, timeout: float = STOP_TIMEOUT): for thread in self.threads: thread.join(max(0.0, deadline - time.monotonic())) self.records.save() + self._save_remote_pins() self.publisher.save() self._save_state() if self.links is not None: @@ -552,6 +558,7 @@ def maintain(self, now: float, last: dict) -> None: if self._refresh_wanted.is_set(): self._refresh_wanted.clear() self.publisher.refresh() + self._save_remote_pins() def _maintenance_loop(self): """Run maintain() about once a second until stopped.""" @@ -899,7 +906,9 @@ def remote_pin(self, identity_hash: bytes, cid: Cid, pin: bool) -> bool: """A /pin from an allowed identity: release at once, or fetch then pin with source remote:. A source's pins are fetched one at a time, so each is checked against its quota with the earlier ones - counted; one with REMOTE_PIN_QUEUE pins waiting is refused (False). + counted; one with REMOTE_PIN_QUEUE pins waiting is refused (False), and so is one whose fetch thread + can't be started. Accepted pins are saved (remote_pins.json) until fetched, and resumed at the next + start if the node stops first. """ source = remote_source(identity_hash) if not pin: @@ -907,6 +916,7 @@ def remote_pin(self, identity_hash: bytes, cid: Cid, pin: bool) -> bool: if cid in self._remote_reserved.get(source, {}): # Still being fetched: it mustn't be pinned when the fetch ends self._remote_released.add((source, cid)) + self._remote_pins_dirty = True log(f"released remote pin of {cid} for {source} while fetching it", LOG_NOTICE) if self.storage.unpin(cid, source): log(f"released remote pin of {cid} for {source}", LOG_NOTICE) @@ -915,7 +925,9 @@ def remote_pin(self, identity_hash: bytes, cid: Cid, pin: bool) -> bool: with self._lock: reserved = self._remote_reserved.setdefault(source, {}) if cid in reserved: - self._remote_released.discard((source, cid)) # pinned again before the fetch ended + if (source, cid) in self._remote_released: + self._remote_released.discard((source, cid)) # pinned again before the fetch ended + self._remote_pins_dirty = True return True if source in self.storage.pins().get(cid, set()): return True @@ -923,29 +935,117 @@ def remote_pin(self, identity_hash: bytes, cid: Cid, pin: bool) -> bool: log(f"refused remote pin of {cid} for {source}: {len(reserved)} of its pins are waiting", LOG_WARNING) return False reserved[cid] = None - if source in self._remote_workers: - return True - self._remote_workers.add(source) - threading.Thread(target=self._remote_pin_worker, args=(source,), name="rnfs-remote-pin", - daemon=True).start() + self._remote_pins_dirty = True + return self._ensure_remote_worker(source, cid) + + def _ensure_remote_worker(self, source: str, cid: Cid) -> bool: + """Start source's fetch thread unless it has one; False if it can't be started, with cid (the pin just + reserved) given back. Caller holds the lock. + + A worker leaves _remote_workers in the same critical section in which it finds nothing waiting, so a + pin reserved after that starts a new one, and one reserved before it is fetched by the old one. + """ + if source in self._remote_workers: + return True + thread = threading.Thread(target=self._remote_pin_worker, args=(source,), name="rnfs-remote-pin", + daemon=True) + try: + thread.start() + except RuntimeError as e: # can't start new thread: out of threads or memory + reserved = self._remote_reserved.get(source, {}) + reserved.pop(cid, None) + if not reserved: + self._remote_reserved.pop(source, None) + self._remote_pins_dirty = True + log(f"refused remote pin of {cid} for {source}: couldn't start its fetch: {e}", LOG_ERROR) + return False + # Registered after start() succeeds, still under the lock, so the worker can't look before it is + self._remote_workers.add(source) return True def _remote_pin_worker(self, source: str): - """Fetch source's remote pins one at a time, oldest first, until none is waiting. + """Fetch source's remote pins one at a time, oldest first, until none is waiting or the node stops. Concurrent fetches would each be checked against the quota without the others' blocks, so together - they could store several times it. + they could store several times it. Pins waiting when the node stops stay saved for the next start. """ - try: - while not self.stop_event.is_set(): - with self._lock: - cid = next(iter(self._remote_reserved.get(source, {})), None) - if cid is None: - return + # Resumed and early pins wait for the join: a lookup before it would find no provider and drop them + while not self.joined.is_set(): + if self.stop_event.wait(0.5): + break + while True: + with self._lock: + cid = None if self.stop_event.is_set() else next(iter(self._remote_reserved.get(source, {})), None) + if cid is None: + # Deregistered in the same critical section that saw nothing waiting: a /pin arriving after + # it starts a new worker instead of relying on this one, which is about to return + self._remote_workers.discard(source) + return + try: self._remote_pin_fetch(cid, source) # removes cid from _remote_reserved when done - finally: + except BaseException: + with self._lock: + self._remote_workers.discard(source) + raise + + def _save_remote_pins(self): + """Save the remote pins waiting or being fetched, in the order accepted (released ones left out), if + they changed, so a restart resumes them. Run by maintenance (about every second) and at stop, not on + every /pin: a crash loses at most the last second's (/pin is best effort, section 8).""" + with self._remote_pins_save_lock: with self._lock: - self._remote_workers.discard(source) + if not self._remote_pins_dirty: + return + waiting = [[source, str(cid)] for source, cids in self._remote_reserved.items() for cid in cids + if (source, cid) not in self._remote_released] + self._remote_pins_dirty = False + try: + write_json(self._remote_pins_path, waiting) + except OSError as e: + with self._lock: + self._remote_pins_dirty = True # try again next time + log(f"saving the remote pins waiting failed: {e!r}", LOG_ERROR) + + def _load_remote_pins(self) -> list[tuple[str, Cid]]: + """Remote pins saved as waiting, oldest first, from identities still on remote_pin_identities (at most + REMOTE_PIN_QUEUE each); others are dropped. Caller holds no lock (it reads a file).""" + allowed = {remote_source(h.strip().strip("<>")) for h in self.config.remote_pin_identities} + try: + saved = read_json(self._remote_pins_path, shape=lambda value: isinstance(value, list)) or [] + except OSError as e: + log(f"reading the remote pins waiting failed: {e!r}", LOG_ERROR) + return [] + pins: dict[tuple[str, Cid], None] = {} + counts: dict[str, int] = {} + for entry in saved: + try: + source, text = entry + cid = Cid.parse(text) + except (ValueError, TypeError): + continue + if source not in allowed or (source, cid) in pins or counts.get(source, 0) >= REMOTE_PIN_QUEUE: + continue + counts[source] = counts.get(source, 0) + 1 + pins[(source, cid)] = None + return list(pins) + + def _resume_remote_pins(self): + """Queue the remote pins saved as waiting when the node last stopped (called once at start).""" + pins = self._load_remote_pins() + resumed = 0 + with self._lock: + pinned = self.storage.pins() + for source, cid in pins: + if source not in pinned.get(cid, set()): + self._remote_reserved.setdefault(source, {})[cid] = None + resumed += 1 + self._remote_pins_dirty = True + for source, cids in list(self._remote_reserved.items()): + # A thread that can't be started gives its pin back, so this ends + while cids and not self._ensure_remote_worker(source, next(iter(cids))): + pass + if resumed: + log(f"resuming {plural(resumed, 'remote pin')} accepted before the last stop", LOG_NOTICE) def _remote_pin_fetch(self, cid: Cid, source: str): """Fetch cid for a remote pin, dropping it as soon as its verified size, or the bytes of its DAG's @@ -987,8 +1087,12 @@ def _remote_pin_fetch(self, cid: Cid, source: str): log(f"remote pin of {cid} for {source} failed: {e!r}", LOG_ERROR) finally: with self._lock: - self._remote_reserved.get(source, {}).pop(cid, None) - self._remote_released.discard((source, cid)) + # Once the node is stopping, a fetch cut short stays waiting, so it is saved for the next start + # (a pin that completed meanwhile is skipped then) + if not self.stop_event.is_set(): + self._remote_reserved.get(source, {}).pop(cid, None) + self._remote_released.discard((source, cid)) + self._remote_pins_dirty = True def gc(self) -> dict: """Evict every cached fetch and delete unreachable blocks; withdraw what is no longer complete.""" diff --git a/tests/security/test_new3_remote_pin_wakeup.py b/tests/security/test_new3_remote_pin_wakeup.py new file mode 100644 index 0000000..e558333 --- /dev/null +++ b/tests/security/test_new3_remote_pin_wakeup.py @@ -0,0 +1,147 @@ +"""NEW-3: a remote /pin accepted while its source's fetch thread was exiting was never fetched. + +RnfsNode._remote_pin_worker found its queue empty under the node lock, released the lock, and only removed +itself from _remote_workers later, in a `finally`. A /pin arriving in between was queued, saw the worker still +registered and answered True without starting one, so nothing fetched it (and sending it again was a no-op). +And if Thread.start() raised, the source stayed registered with no thread: its later pins queued up to +REMOTE_PIN_QUEUE and were then refused until a restart. + +The window is hit deterministically by giving the node a lock that runs a hook right after the worker's +critical section that finds nothing waiting: the hook sends the second /pin from another thread, as an RNS +request thread would. The fixed worker leaves _remote_workers inside that same critical section, so the +second /pin starts a new worker and is fetched. The thread-start failure is simulated by patching +threading.Thread.start in rnfs.node. +""" +import io +import os +import sys +import tempfile +import threading +import unittest +from unittest import mock + +import RNS + +from rnfs import node as node_module +from rnfs.constants import CHUNK_SIZE +from rnfs.content.cid import Cid +from rnfs.content.storage import remote_source +from tests.security import _pages_helpers as helpers +from tests.support import wait_for + +IDENTITY = bytes(range(16)) +SOURCE = remote_source(IDENTITY) + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class HookLock: + """A threading.Lock stand-in that runs hook() once, right after the remote-pin worker releases it with + nothing left waiting for SOURCE (the worker's "queue empty" critical section).""" + + def __init__(self, node): + """Wrap a fresh lock for node.""" + self._lock = threading.Lock() + self.node = node + self.hook = None # run once (it is cleared before it runs, so the workers it starts don't run it) + self.fired = threading.Event() + + def acquire(self, *args, **kwargs): + """As threading.Lock.acquire.""" + return self._lock.acquire(*args, **kwargs) + + def release(self): + """As threading.Lock.release.""" + self._lock.release() + + def __enter__(self): + """Take the lock.""" + self._lock.acquire() + return self + + def __exit__(self, *exc): + """Release, then run the hook if this was the worker finding its queue empty.""" + in_worker = sys._getframe(1).f_code.co_name == "_remote_pin_worker" + empty = not self.node._remote_reserved.get(SOURCE) + self._lock.release() + hook, self.hook = (self.hook, None) if in_worker and empty else (None, self.hook) + if hook is not None: + hook() + self.fired.set() # after the hook: its /pin has been answered + return False + + +class RemotePinWakeupTest(unittest.TestCase): + """A provider with two files, and a node that gets /pin requests for them from one allowed identity.""" + + def setUp(self): + """Two offline nodes joined by a NetClient (as the H2 tests set them up), and two files on the provider.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + # make_nodes' second node (B) already knows the first (A): here A provides and B is asked to pin + _, self.provider, self.a = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.provider.stop) + self.addCleanup(self.a.stop) + self.cids = [] + for _ in range(2): + cid = Cid.parse(self.provider.add_stream(io.BytesIO(os.urandom(2 * CHUNK_SIZE)))["cid"]) + helpers.advertise(self.provider, self.a, cid) + self.cids.append(cid) + + def pinned(self, cid) -> bool: + """True once the pinning node holds cid with the remote source.""" + return SOURCE in self.a.storage.pins().get(cid, set()) + + def test_pin_accepted_while_the_worker_exits_is_fetched(self): + """A /pin that lands between the worker's empty check and its exit is still fetched and pinned.""" + lock = HookLock(self.a) + self.a._lock = lock + self.addCleanup(setattr, self.a, "_lock", threading.Lock()) + answers = [] + + def second_pin(): + """The second /pin, on another thread (as RNS calls request handlers), while the worker exits.""" + thread = threading.Thread(target=lambda: answers.append( + self.a.backend.remote_pin(IDENTITY, self.cids[1], True))) + thread.start() + thread.join(10) + + lock.hook = second_pin + self.assertTrue(self.a.backend.remote_pin(IDENTITY, self.cids[0], True)) + self.assertTrue(lock.fired.wait(10), "the worker never reached its empty-queue exit") + self.assertEqual(answers, [True]) + self.assertTrue(wait_for(lambda: self.pinned(self.cids[0]), 10)) + # NEW-3: fails until the worker deregisters in the same critical section that finds its queue empty + self.assertTrue(wait_for(lambda: self.pinned(self.cids[1]), 10), + "the /pin accepted while the worker was exiting was never fetched " + f"(still queued: {self.cids[1] in self.a._remote_reserved.get(SOURCE, {})}, " + f"worker registered: {SOURCE in self.a._remote_workers})") + + def test_thread_start_failure_refuses_the_pin_and_registers_nothing(self): + """If the fetch thread can't be started the /pin is refused, nothing stays queued or registered, and + the next /pin from that source works.""" + with mock.patch.object(node_module.threading.Thread, "start", side_effect=RuntimeError("can't start new thread")): + try: + accepted = self.a.backend.remote_pin(IDENTITY, self.cids[0], True) + except RuntimeError: + accepted = "raised" # unhandled: the RNS request handler would answer nothing + # NEW-3: fails until a failed Thread.start() is rolled back under the lock and answered False + self.assertEqual((accepted, SOURCE in self.a._remote_workers, dict(self.a._remote_reserved.get(SOURCE, {}))), + (False, False, {}), + "a thread that never started left the source registered or its pin queued") + self.assertTrue(self.a.backend.remote_pin(IDENTITY, self.cids[1], True)) + self.assertTrue(wait_for(lambda: self.pinned(self.cids[1]), 10)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_new6_remote_pins_restart.py b/tests/security/test_new6_remote_pins_restart.py new file mode 100644 index 0000000..6b2b4ee --- /dev/null +++ b/tests/security/test_new6_remote_pins_restart.py @@ -0,0 +1,144 @@ +"""NEW-6 (remote-pin half): remote pins accepted but not yet fetched were lost when the node restarted. + +RnfsNode kept accepted /pin requests only in memory (_remote_reserved). The requester got True, but if the +node stopped before fetching them, nothing fetched them after the restart, and nothing said so. The fixed +node saves the pins waiting (state/remote_pins.json, in the order accepted) and resumes them at start; pins +released before the stop, or from identities no longer on remote_pin_identities, are not resumed. + +The tests use two offline nodes joined by tests.test_node's NetClient. The provider's /blocks answers are held +while the pinning node stops, so its pins are still waiting then; the node is then reopened over the same +directory and started the way RnfsNode.__init__ starts it (_start_threads). +""" +import io +import os +import tempfile +import threading +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE +from rnfs.content.cid import Cid +from rnfs.content.storage import remote_source +from rnfs.network.client import BlocksResult +from rnfs.node import RnfsNode +from tests.support import wait_for +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, B_ID, NetClient + +IDENTITY = bytes(range(16)) +SOURCE = remote_source(IDENTITY) + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class RemotePinsRestartTest(unittest.TestCase): + """Provider A holds files; node B accepts /pin for them from IDENTITY, then restarts.""" + + def setUp(self): + """The provider, the pinning node, and files the pinning node can find on the provider.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.net = NetClient() + self.net.sender = B_ID + self.provider = self.open("a", A_ID) + self.node = self.open("b", B_ID) + self.cids = [Cid.parse(self.provider.add_stream(io.BytesIO(os.urandom(2 * CHUNK_SIZE)))["cid"]) + for _ in range(2)] + for cid in self.cids: + self.provider.records.store(cid.key, A_ID) + self.real_blocks = self.net.blocks + + def open(self, name, node_id, **settings) -> RnfsNode: + """An offline node over /name that accepts /pin from IDENTITY (unless settings say otherwise).""" + settings.setdefault("remote_pin_identities", [IDENTITY.hex()]) + config = Config(rnfs_dir=os.path.join(self.tmp.name, name), slow="no", lookup_timeout=5, path_timeout=0.5, + default_bootstrap=False, **settings) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork()) + self.net.nodes[node_id] = node + self.addCleanup(node.stop) + if node_id == B_ID: + node.routing.heard(A_ID) + return node + + def stop_while_waiting(self, *pins): + """Accept /pin for pins while the provider's answers are held, then stop the node with them waiting.""" + asked, go = threading.Event(), threading.Event() + + def held(node_id, cids, on_progress=None): + """Hold every /blocks until the test lets it go, then fail it.""" + asked.set() + go.wait(10) + return BlocksResult("failed", unsent=list(cids)) + + self.net.blocks = held + for cid in pins: + self.assertTrue(self.node.backend.remote_pin(IDENTITY, cid, True)) + self.assertTrue(asked.wait(10)) + self.node.stop() + go.set() + self.net.blocks = self.real_blocks + + def restart(self, **settings) -> RnfsNode: + """Reopen the pinning node over the same directory and start it as RnfsNode.__init__ does.""" + self.node = self.open("b", B_ID, **settings) + self.node._start_threads() + return self.node + + def pinned(self, node, cid) -> bool: + """True if node holds cid with the remote source.""" + return SOURCE in node.storage.pins().get(cid, set()) + + def test_waiting_pins_are_resumed_after_a_restart(self): + """Pins accepted and still waiting (one being fetched, one queued) are fetched after the restart.""" + self.stop_while_waiting(*self.cids[:2]) + node = self.restart() + # NEW-6: fails until accepted remote pins are saved and re-queued at start + self.assertTrue(wait_for(lambda: all(self.pinned(node, cid) for cid in self.cids[:2]), 15), + "remote pins accepted before the restart were never fetched") + + def test_release_before_the_stop_is_kept(self): + """A pin released before the stop isn't fetched after the restart.""" + asked, go = threading.Event(), threading.Event() + + def held(node_id, cids, on_progress=None): + """Hold every /blocks until the test lets it go, then fail it.""" + asked.set() + go.wait(10) + return BlocksResult("failed", unsent=list(cids)) + + self.net.blocks = held + for cid in self.cids[:2]: + self.assertTrue(self.node.backend.remote_pin(IDENTITY, cid, True)) + self.assertTrue(asked.wait(10)) + self.assertTrue(self.node.backend.remote_pin(IDENTITY, self.cids[1], False)) # released while queued + self.node.stop() + go.set() + self.net.blocks = self.real_blocks + node = self.restart() + self.assertTrue(wait_for(lambda: self.pinned(node, self.cids[0]), 15)) + self.assertTrue(wait_for(lambda: not node._remote_reserved.get(SOURCE), 15)) + self.assertFalse(self.pinned(node, self.cids[1])) + + def test_identity_removed_from_the_list_is_not_resumed(self): + """Pins of an identity taken off remote_pin_identities before the restart are dropped.""" + self.stop_while_waiting(self.cids[0]) + node = self.restart(remote_pin_identities=[]) + self.assertEqual(node._remote_reserved.get(SOURCE, {}), {}) # the resume queues synchronously + self.assertNotIn(SOURCE, node._remote_workers) + self.assertFalse(self.pinned(node, self.cids[0])) + + +if __name__ == "__main__": + unittest.main() From 1ffc8eb5ec6a62f68fa7369c481195e614eee1d0 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:18 +1030 Subject: [PATCH 056/107] Keep and hand out only members' provider records in private mode, and link only to members --- rnfs/dht/providers.py | 20 +++ rnfs/network/links.py | 7 +- rnfs/node.py | 29 ++- .../test_new4_private_mode_records.py | 165 ++++++++++++++++++ 4 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 tests/security/test_new4_private_mode_records.py diff --git a/rnfs/dht/providers.py b/rnfs/dht/providers.py index 20cd23a..77218ff 100644 --- a/rnfs/dht/providers.py +++ b/rnfs/dht/providers.py @@ -200,6 +200,26 @@ def provide(self, publisher: bytes, keys: Iterable[bytes], withdrawn: Iterable[b self.withdraw(key, publisher) return sum(self.store(key, publisher) for key in keys) + def retain(self, keep: Callable[[bytes], bool]) -> int: + """Remove every record of publishers for which keep(publisher) is False (own_id's are always kept), as + private mode does with non-members' records. Returns how many records were removed. + + keep is asked once per publisher, outside the lock (it may consult other locked state, like RNS's). + """ + with self._lock: + publishers = [publisher for publisher in self._publishers if publisher != self.own_id] + dropped = {publisher for publisher in publishers if not keep(publisher)} + removed = 0 + with self._lock: + for publisher in dropped: + entry = self._publishers.get(publisher) + for key in list(entry.keys) if entry is not None else (): + removed += self._remove(key, publisher) + # Not kept as departed either: its standing here came from records it shouldn't have had + self._departed.pop(publisher, None) + self._dirty = self._dirty or removed > 0 + return removed + def allowance(self, publisher: bytes) -> int | None: """How many records publisher may hold now: None (no limit) for own_id.""" with self._lock: diff --git a/rnfs/network/links.py b/rnfs/network/links.py index d487156..a66e4ef 100644 --- a/rnfs/network/links.py +++ b/rnfs/network/links.py @@ -6,7 +6,7 @@ - A link goes only to a destination whose hash is the node ID asked for (rnfs.dht of the identity RNS recalls for it), so one identity can't pose as many node IDs by announcing other destinations. - Publishing and /pin use a separate link that identifies, closed when done. -- In private mode every link identifies. +- In private mode every link identifies, and links go only to members (identities on `members`). - Requesters stay within the per-link limits: at most MAX_REQUESTS_PER_LINK requests in progress on a link, at most one of them /blocks. A request waits for a free slot. - RNS stops a request's timeout once a response Resource starts arriving, so a node could trickle an answer @@ -173,6 +173,8 @@ def __init__(self, identity: RNS.Identity, config: Config, clock: Callable[[], f self.identity = identity self.config = config self.clock = clock + # Private mode links only to members: every link identifies, and an outsider mustn't learn who we are + self.members = {h.strip().strip("<>").lower() for h in config.members} self.shared: dict[bytes, PeerLink] = {} self.oneshot: set[PeerLink] = set() self.connecting: dict[bytes, threading.Event] = {} # one thread opens a node's shared link at a time @@ -264,6 +266,9 @@ def _open(self, peer: PeerLink) -> bool: log(f"the identity known for {name} isn't that node's (its rnfs.dht is " f"{node_name(destination.hash)}); not connecting", LOG_VERBOSE) return False + if self.config.private and identity.hash.hex() not in self.members: + log(f"{name} isn't a member; not connecting in private mode", LOG_VERBOSE) + return False # Set on close too, so a failed link doesn't wait out the full timeout settled = threading.Event() diff --git a/rnfs/node.py b/rnfs/node.py index c7b6732..45c61c0 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -177,9 +177,10 @@ def is_dht_server(self) -> bool: return self.node.config.dht_mode == "server" def find(self, key: bytes) -> tuple[list[bytes], list[bytes]]: - """Providers of key (oldest half plus a random half, banned left out) and the closest servers.""" + """Providers of key (oldest half plus a random half, banned left out, and in private mode non-members) + and the closest servers.""" routing = self.node.routing - providers = self.node.records.for_reply(key, exclude=routing.is_banned) + providers = self.node.records.for_reply(key, exclude=self.node.excluded_provider) nodes = [n for n in routing.closest(key, for_reply=True) if n != self.node.node_id] return providers, nodes @@ -187,6 +188,9 @@ def provide(self, publisher_id: bytes, keys: list[bytes], withdrawn: list[bytes] """Record publisher_id for keys; True only if every key was recorded. Banned publishers are ignored.""" if not self.is_dht_server() or self.node.routing.is_banned(publisher_id): return False + if self.node.config.private: + # The request server let it through as a member (private mode checks every handler's requester) + self.node._member_publishers.add(publisher_id) return self.node.records.provide(publisher_id, keys, withdrawn) == len(keys) def served_block(self, cid: Cid) -> bytes | None: @@ -273,6 +277,8 @@ def _setup(self, config: Config, node_id: bytes, client, slow: bool, names_netwo self.slow = slow self._is_known = is_known self.members = {h.strip().strip("<>").lower() for h in config.members} + # Private mode: node IDs that sent /provide as a member this run (their identity was checked then) + self._member_publishers: set[bytes] = set() self._bootstrap_nodes: list[bytes] = [] # bootstrap_nodes that parse, in the order given for text in config.bootstrap_nodes: try: @@ -284,6 +290,11 @@ def _setup(self, config: Config, node_id: bytes, client, slow: bool, names_netwo self.routing = RoutingTable(node_id, config, admit=self._is_member_node if config.private else None) self.records = ProviderStore(config.max_records, os.path.join(config.state_dir, PROVIDERS_FILE), own_id=node_id, seniority=config.provider_seniority) + if config.private: + # Records stored while the node was public, or from a member removed since, are never handed out + removed = self.records.retain(self._is_member_publisher) + if removed: + log(f"private mode: dropped {plural(removed, 'provider record')} of non-members", LOG_NOTICE) self.storage = Storage(config.blocks_dir, config.state_dir, config.max_storage) self.shared_folder = SharedFolder(config.files_dir, self.storage, config.state_dir) self.pin_names = PinNames(config.state_dir) # where pins came from, for pin ls (local only) @@ -409,6 +420,18 @@ def _is_member_node(self, node_id: bytes) -> bool: identity = RNS.Identity.recall(node_id) return identity is not None and identity.hash.hex() in self.members + def _is_member_publisher(self, node_id: bytes) -> bool: + """Private mode: True if node_id's provider records may be kept and handed out (this node, a node that + provided as a member this run, or one whose identity RNS knows to be a member).""" + return node_id == self.node_id or node_id in self._member_publishers or self._is_member_node(node_id) + + def excluded_provider(self, node_id: bytes) -> bool: + """True if a provider must be left out of /find replies and providers(): banned, or in private mode + not a member.""" + if self.routing.is_banned(node_id): + return True + return self.config.private and not self._is_member_publisher(node_id) + def _seed_routing(self): """Add servers whose announces RNS heard (and cached) within 2 × announce_interval, before a restart. @@ -1115,7 +1138,7 @@ def verify(self) -> dict: def providers(self, cid: str) -> list[bytes]: """Nodes providing cid: records held here, then everything a complete provider lookup finds.""" parsed = Cid.parse(cid) - found: list[bytes] = list(self.records.providers(parsed.key, exclude=self.routing.is_banned)) + found: list[bytes] = list(self.records.providers(parsed.key, exclude=self.excluded_provider)) def add(node_id: bytes): """Collect a provider the lookup reports.""" diff --git a/tests/security/test_new4_private_mode_records.py b/tests/security/test_new4_private_mode_records.py new file mode 100644 index 0000000..46b6836 --- /dev/null +++ b/tests/security/test_new4_private_mode_records.py @@ -0,0 +1,165 @@ +"""NEW-4: in private mode, /find replies listed provider records of non-members stored before the switch. + +NodeBackend.find filtered providers only by bans, and ProviderStore._load reloaded providers.json with no +membership check. The request server refuses non-members' /provide once private mode is on, but records +already held (stored while the node was public, or from a member removed since) kept being handed to members +for up to PROVIDER_TTL, and a member's client then opened identified links to those outsiders. + +The tests run offline nodes over the same state directory, first public (or with a longer member list), then +private, with RNS.Identity.recall patched to know which node IDs belong to member identities (as RNS would +from their announces). They check /find replies, the facade's providers() and the records kept, and that a +private node's LinkPool doesn't link to (and so identify to) a non-member. +""" +import os +import tempfile +import types +import unittest +from unittest import mock + +import RNS + +from rnfs import node as node_module +from rnfs.config import Config +from rnfs.content.cid import RAW, Cid +from rnfs.network import links +from rnfs.node import RnfsNode +from tests.test_links import fake_rns, run +from tests.test_names import FakeNetwork +from tests.test_node import NetClient + +OWN = bytes([0xA] * 16) +OUTSIDER = bytes([0x66] * 16) # node ID of a non-member that provided while the node was public +MEMBER = bytes([0x77] * 16) # node ID of a member +FORMER = bytes([0x88] * 16) # node ID of a member removed from the list later +IDENTITIES = {OUTSIDER: bytes([0x16] * 16), MEMBER: bytes([0x17] * 16), FORMER: bytes([0x18] * 16)} +CID = Cid.for_block(RAW, b"provided by everyone") +KEY = CID.key + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +def recall(node_id): + """RNS.Identity.recall as RNS would answer it for the test's node IDs (their announces were heard).""" + identity_hash = IDENTITIES.get(node_id) + return None if identity_hash is None else types.SimpleNamespace(hash=identity_hash) + + +class PrivateModeRecordsTest(unittest.TestCase): + """One node's state directory, opened public then private.""" + + def setUp(self): + """A temporary rnfs dir, and RNS.Identity.recall answering for the test's node IDs.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = os.path.join(self.tmp.name, "a") + patcher = mock.patch.object(node_module.RNS.Identity, "recall", side_effect=recall) + patcher.start() + self.addCleanup(patcher.stop) + + def open(self, **settings) -> RnfsNode: + """The node over self.root, offline, with these settings.""" + config = Config(rnfs_dir=self.root, slow="no", lookup_timeout=1, path_timeout=0.2, **settings) + node = RnfsNode.offline(config, OWN, NetClient(), FakeNetwork()) + self.addCleanup(node.stop) + return node + + def members(self, *node_ids): + """Settings for private mode with these node IDs' identities as members.""" + return {"private": True, "members": [IDENTITIES[node_id].hex() for node_id in node_ids]} + + def test_records_from_before_private_mode_are_not_handed_out(self): + """Records an outsider stored while the node was public are dropped when it starts in private mode.""" + public = self.open() + for publisher in (OUTSIDER, MEMBER): + self.assertTrue(public.backend.provide(publisher, [KEY], [])) + public.records.store(KEY, OWN) + public.stop() # saves providers.json + + private = self.open(**self.members(MEMBER)) + providers, _ = private.backend.find(KEY) + # NEW-4: fails until private mode leaves non-members out of /find replies and purges their records + self.assertNotIn(OUTSIDER, providers, "a /find reply in private mode lists a non-member's record") + self.assertEqual(sorted(providers), sorted([MEMBER, OWN])) + self.assertNotIn(OUTSIDER, private.records.providers(KEY)) # gone, not just hidden + with mock.patch.object(private, "_lookup") as lookup: + lookup.return_value.find_providers = lambda add: None + self.assertNotIn(OUTSIDER, private.providers(str(CID))) + + def test_records_of_a_removed_member_are_dropped(self): + """A member taken off the list loses its records at the next start.""" + before = self.open(**self.members(MEMBER, FORMER)) + for publisher in (MEMBER, FORMER): + self.assertTrue(before.backend.provide(publisher, [KEY], [])) + before.stop() + + after = self.open(**self.members(MEMBER)) + providers, _ = after.backend.find(KEY) + # NEW-4: fails until records of identities no longer on the members list are dropped + self.assertEqual(providers, [MEMBER], "a removed member's record is still handed out") + + def test_reply_leaves_out_a_non_member_record_stored_while_private(self): + """Whatever put it there, a non-member's record isn't handed out in private mode; a publisher the + server accepted as a member is, even if RNS doesn't recall its identity.""" + private = self.open(**self.members(MEMBER)) + unknown_member = bytes([0x99] * 16) # a member's node ID that RNS hasn't heard an announce for + private.records.store(KEY, OUTSIDER) + self.assertTrue(private.backend.provide(unknown_member, [KEY], [])) # the server checked membership + providers, _ = private.backend.find(KEY) + # NEW-4: fails until /find replies in private mode are filtered by membership + self.assertEqual(providers, [unknown_member], "a non-member's record was handed out in private mode") + + def test_public_mode_is_unchanged(self): + """Outside private mode every record is handed out as before.""" + public = self.open() + public.backend.provide(OUTSIDER, [KEY], []) + self.assertEqual(public.backend.find(KEY)[0], [OUTSIDER]) + + +class PrivateModeLinksTest(unittest.TestCase): + """The client side: a private node's LinkPool links (and identifies) only to members.""" + + def setUp(self): + """RNS in the links module replaced by tests.test_links' fake, recalling identities with hashes.""" + rns = fake_rns() + rns.Identity = types.SimpleNamespace( + recall=lambda node_id: types.SimpleNamespace(node_id=node_id, hash=IDENTITIES.get(node_id, bytes(16)))) + patcher = mock.patch.object(links, "RNS", rns) + patcher.start() + self.addCleanup(patcher.stop) + self.identity = object() + + def pool(self, **settings) -> links.LinkPool: + """A LinkPool with these settings.""" + pool = links.LinkPool(self.identity, Config(**settings)) + self.addCleanup(pool.close_all) + return pool + + def test_private_node_does_not_link_to_a_non_member(self): + """An outsider listed as a provider (by a stale record anywhere) gets no link and no identification.""" + pool = self.pool(private=True, members=[IDENTITIES[MEMBER].hex()]) + # NEW-4 (client side): fails until private mode opens links only to members + self.assertIsNone(run(self, lambda: pool.get(OUTSIDER)), "a private node linked to a non-member") + self.assertIsNone(run(self, lambda: pool.open_identified(OUTSIDER))) + member = run(self, lambda: pool.get(MEMBER)) + self.assertIsNotNone(member) + self.assertEqual(member.link.identities, [self.identity]) + + def test_public_node_links_to_anyone(self): + """Outside private mode, links go to any node, unidentified.""" + peer = run(self, lambda: self.pool().get(OUTSIDER)) + self.assertIsNotNone(peer) + self.assertEqual(peer.link.identities, []) + + +if __name__ == "__main__": + unittest.main() From d74dcc5aa0d925037228db7877f8ac2f1c8fc717 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:22 +1030 Subject: [PATCH 057/107] Refuse a /provide from a publisher at its limit without rescanning its records for every key --- rnfs/dht/providers.py | 32 ++++++- tests/security/test_new8_provide_at_limit.py | 98 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/security/test_new8_provide_at_limit.py diff --git a/rnfs/dht/providers.py b/rnfs/dht/providers.py index 77218ff..d7856c1 100644 --- a/rnfs/dht/providers.py +++ b/rnfs/dht/providers.py @@ -62,9 +62,11 @@ def __init__(self, first_stored: float, last_stored: float, pooled: bool = False class _Publisher: """A publisher with records here: when it was first seen, which keys it provides, and how many of its - records are outside the pool. `order` breaks first-seen ties (the one stored later is newer).""" + records are outside the pool. `order` breaks first-seen ties (the one stored later is newer). + `sweep_after`: none of its records can expire before this time (known from its last sweep for expired + records; later stores only add later expiries), so a sweep before then would find nothing.""" - __slots__ = ("first_seen", "order", "keys", "main") + __slots__ = ("first_seen", "order", "keys", "main", "sweep_after") def __init__(self, first_seen: float, order: int): """Publisher first seen at first_seen, with no keys yet.""" @@ -72,6 +74,7 @@ def __init__(self, first_seen: float, order: int): self.order = order self.keys: set[bytes] = set() self.main = 0 + self.sweep_after = float("-inf") class ProviderStore: @@ -190,6 +193,8 @@ def provide(self, publisher: bytes, keys: Iterable[bytes], withdrawn: Iterable[b """Apply one /provide batch from publisher: withdraw `withdrawn`, then store `keys`. Returns how many of `keys` were recorded (the rest were refused: the store is full or a limit applied). + A publisher at its allowance costs little: its expired records are swept at most once per batch (none + while none can have expired), and each new key is then refused without scanning its records again. """ check_id(publisher) keys, withdrawn = list(keys), list(withdrawn) @@ -244,7 +249,11 @@ def _within_limits(self, key: bytes, publisher: bytes, now: float) -> bool: entry = self._publishers.get(publisher) allowance = self._allowance(publisher, now) if entry is not None and allowance is not None and len(entry.keys) >= allowance: - self._drop_expired([(owned, publisher) for owned in entry.keys], now) + # At its limit: refused at once unless one of its records may have expired since its last sweep + # (scanning all of them for every key of a refused batch made each refusal cost thousands of checks) + if now < entry.sweep_after: + return False + self._sweep_publisher(publisher, entry, now) if publisher in self._publishers and len(self._publishers[publisher].keys) >= allowance: return False records = self._records.get(key) @@ -259,6 +268,18 @@ def _within_limits(self, key: bytes, publisher: bytes, now: float) -> bool: self._dirty = True return True + def _sweep_publisher(self, publisher: bytes, entry: _Publisher, now: float): + """Remove publisher's expired records, and note when the next of the rest expires. Caller holds the lock.""" + earliest = float("inf") + for key in list(entry.keys): + record = self._records[key][publisher] + if now - record.last_stored >= self.ttl: + self._remove(key, publisher) + self._dirty = True + else: + earliest = min(earliest, record.last_stored) + entry.sweep_after = earliest + self.ttl + def _drop_expired(self, pairs: list[tuple[bytes, bytes]], now: float): """Remove those of the (key, publisher) records given that have expired by now. Caller holds the lock.""" for key, publisher in pairs: @@ -426,16 +447,19 @@ def _expire(self, now: float) -> int: def _clamp(self, now: float): """Bring every time after now back to now (the clock was corrected backwards). Caller holds the lock.""" + stored_moved = False for records in self._records.values(): for record in records.values(): if record.last_stored > now: record.last_stored = now - self._dirty = True + stored_moved = self._dirty = True if record.first_stored > now: record.first_stored = now self._dirty = True moved = False for entry in self._publishers.values(): + if stored_moved: + entry.sweep_after = float("-inf") # expiries moved earlier: its next sweep can't be skipped if entry.first_seen > now: entry.first_seen = now moved = True diff --git a/tests/security/test_new8_provide_at_limit.py b/tests/security/test_new8_provide_at_limit.py new file mode 100644 index 0000000..91d5648 --- /dev/null +++ b/tests/security/test_new8_provide_at_limit.py @@ -0,0 +1,98 @@ +"""NEW-8: a /provide from a publisher at its record limit cost thousands of expiry checks per refused key. + +ProviderStore.provide stored each key of the batch in turn; for a publisher already holding its allowance, +_within_limits then scanned all of that publisher's records for expired ones before refusing, for every key. +At the default max_records (100 000: 1000 records per publisher) a refused 1 KiB batch cost tens of +thousands of checks (about 5 ms) under the store's lock, which /find replies take too. The fixed store sweeps +a publisher's expired records at most once per batch, skips the sweep while none of its records can have +expired yet, and refuses each further key at once. + +The work is counted, not timed: the store's ttl is a float that counts how often a record's age is compared +with it, which is what each expiry check does. The clock is fixed (a FakeClock-style callable). +""" +import os +import unittest + +from rnfs.constants import PROVIDE_PATH +from rnfs.dht.providers import ProviderStore +from rnfs.network import protocol + +TTL = 1000.0 + + +class CountingTtl(float): + """A ttl that counts the comparisons `age >= ttl` make (Python asks the float subclass on the right).""" + + checks = 0 + + def __le__(self, other): + """other >= self: one expiry check.""" + CountingTtl.checks += 1 + return float.__le__(self, other) + + def __gt__(self, other): + """other < self: one liveness check.""" + CountingTtl.checks += 1 + return float.__gt__(self, other) + + +def batch_keys() -> list[bytes]: + """As many new keys as fit in one /provide request on a large-MTU link (the most one request can carry).""" + keys = [] + while protocol.fits(PROVIDE_PATH, protocol.encode_provide(keys + [os.urandom(16)], []), 10_000): + keys.append(os.urandom(16)) + return keys + + +class ProvideAtLimitTest(unittest.TestCase): + """A publisher holding its whole allowance in a store of the default size.""" + + def setUp(self): + """A store at the default max_records, with one publisher at its limit.""" + self.now = [10_000.0] + self.store = ProviderStore(100_000, None, ttl=CountingTtl(TTL), clock=lambda: self.now[0]) + self.publisher = os.urandom(16) + self.allowance = self.store.max_per_publisher + for _ in range(self.allowance): + self.assertTrue(self.store.store(os.urandom(16), self.publisher)) + self.keys = batch_keys() + self.assertGreater(len(self.keys), 10) + + def checks_for(self, keys) -> tuple[int, int]: + """(keys stored, expiry checks made) for one /provide batch of keys from the publisher.""" + CountingTtl.checks = 0 + stored = self.store.provide(self.publisher, keys) + return stored, CountingTtl.checks + + def test_refused_batch_scans_the_publishers_records_at_most_once(self): + """A refused batch costs at most one pass over the publisher's records plus a check per key.""" + stored, checks = self.checks_for(self.keys) + self.assertEqual(stored, 0) + # NEW-8: fails until the per-publisher limit is checked once per batch, not once per key + self.assertLessEqual(checks, self.allowance + 2 * len(self.keys), + f"a refused batch of {len(self.keys)} keys made {checks} expiry checks") + + def test_next_refused_batch_skips_the_sweep(self): + """While none of its records can have expired, a refused batch doesn't scan them at all.""" + self.checks_for(self.keys) + self.now[0] += TTL / 2 # nothing stored at the start has expired yet + stored, checks = self.checks_for([os.urandom(16) for _ in self.keys]) + self.assertEqual(stored, 0) + # NEW-8: fails until a sweep that can't find anything expired is skipped + self.assertLessEqual(checks, 2 * len(self.keys), f"{checks} expiry checks with nothing expired") + + def test_expired_records_still_make_room(self): + """Once records have expired, the publisher can store again, in the same batch that finds them.""" + self.checks_for(self.keys) + self.now[0] += TTL # every record has expired + stored, _ = self.checks_for(self.keys) + self.assertEqual(stored, len(self.keys)) + + def test_refreshes_are_still_accepted_at_the_limit(self): + """A publisher at its limit may still republish keys it holds.""" + held = next(iter(self.store._publishers[self.publisher].keys)) + self.assertEqual(self.store.provide(self.publisher, [held] + self.keys), 1) + + +if __name__ == "__main__": + unittest.main() From 863f0b77dd3d9a70aecfcdd953a88a37e9537191 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:26 +1030 Subject: [PATCH 058/107] Answer GET /api/status without the API token with the version only, and send the token from rnfs status --- rnfs/api.py | 12 +- rnfs/cli.py | 28 +++-- tests/security/test_new7_api_status_open.py | 117 ++++++++++++++++++++ 3 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 tests/security/test_new7_api_status_open.py diff --git a/rnfs/api.py b/rnfs/api.py index 6930e9a..ec68cea 100644 --- a/rnfs/api.py +++ b/rnfs/api.py @@ -6,7 +6,7 @@ | Method | Path | Parameters | Response / facade call | |---|---|---|---| -| GET | /api/status | | `status()` | +| GET | /api/status | | `status()`; without the token only `{"version"}` | | GET | /api/info | `target` | `info(target)` | | GET | /api/cat | `target`, `offset`, `length`, `raw`, `block` | the bytes of a file (`read`; sealed files decrypted unless `raw`), Content-Length set; `block=1`: the CID's own block | | GET | /api/ls | `target` | `{"entries": ls(target)}` | @@ -42,7 +42,8 @@ connect to 127.0.0.1, and the API reads and writes files as the daemon's user, so only those who can read the rnfs directory may use it. A custom header also forces a CORS preflight, which fails because the API never sends CORS headers, and a web page can't know the token anyway. Status stays open for - health checks. + health checks and the CLI's proof check below, but without the token it answers only `{"version": ...}`: + the node and identity hashes, LXMF address and traffic counters need the token like everything else. - A request carrying `X-Rnfs-Challenge: <32 hex digits>` gets `X-Rnfs-Proof: ` in its response, which only a server knowing the token can make. Before it first sends the token, the CLI makes whatever listens on the port prove this (with a status request), so another local user listening there @@ -70,7 +71,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlsplit -from rnfs import fsutil +from rnfs import __version__, fsutil from rnfs import node_content # --- content: add options (pin, to, chunker) --- from rnfs.cli import CHALLENGE_HEADER, PROOF_HEADER, api_proof from rnfs.content.cid import Cid @@ -739,7 +740,10 @@ def json_body(self) -> dict: # GET endpoints def api_status(self): - """GET /api/status: the node's summary.""" + """GET /api/status: the node's summary with the token; without it, only the version (a health check).""" + if not self.token_valid(): + self.send_json(200, {"version": __version__}) + return self.send_json(200, self.node.status()) def api_info(self): diff --git a/rnfs/cli.py b/rnfs/cli.py index 4bab375..d5890ee 100644 --- a/rnfs/cli.py +++ b/rnfs/cli.py @@ -101,7 +101,8 @@ def read_token(path: str) -> str: class ApiClient: """Talks to the daemon's API on 127.0.0.1:port, exiting with a friendly message on any error. - token_file is read on the first request that needs it: status needs no token, for health checks. + token_file is read on the first request: every request carries the token but the proof check's status + request (status without the token says only the daemon's version, for health checks). """ def __init__(self, port: int, token_file: str): @@ -110,15 +111,17 @@ def __init__(self, port: int, token_file: str): self.token: str | None = None def request(self, method: str, path: str, params: dict | None = None, json_body: dict | None = None, - body=None, length: int | None = None, extra_headers: dict | None = None) -> http.client.HTTPResponse: + body=None, length: int | None = None, extra_headers: dict | None = None, + authenticated: bool = True) -> http.client.HTTPResponse: """Send a request and return the successful response; exits on errors. - body may be bytes or a file; a file without length is sent chunked. + body may be bytes or a file; a file without length is sent chunked. Unless authenticated is False (the + proof check), the token is sent, once the daemon has proved it knows it. """ url = path + ("?" + urlencode({k: v for k, v in params.items() if v is not None}) if params else "") # Host is set explicitly: the API only accepts 127.0.0.1:, even for port 80 headers = {"Host": f"127.0.0.1:{self.port}", **(extra_headers or {})} - if (method, path) != ("GET", "/api/status"): + if authenticated: if self.token is None: token = read_token(self.token_file) self.verify_daemon(token) @@ -154,7 +157,8 @@ def verify_daemon(self, token: str): """Exit unless whatever listens on the API port proves it knows token, before the token (or a name key) is sent to it: while the daemon is down, any local user could be listening on its port.""" challenge = secrets.token_hex(16) - response = self.request("GET", "/api/status", extra_headers={CHALLENGE_HEADER: challenge}) + response = self.request("GET", "/api/status", extra_headers={CHALLENGE_HEADER: challenge}, + authenticated=False) read_all(response) proof = (response.getheader(PROOF_HEADER) or "").strip().encode("ascii", "replace") if not hmac.compare_digest(proof, api_proof(token, self.port, challenge).encode()): @@ -163,9 +167,9 @@ def verify_daemon(self, token: str): f"(or one started before an upgrade: restart it)") def call(self, method: str, path: str, params: dict | None = None, json_body: dict | None = None, - body=None, length: int | None = None): + body=None, length: int | None = None, authenticated: bool = True): """Send a request and return its decoded JSON response.""" - response = self.request(method, path, params, json_body, body, length) + response = self.request(method, path, params, json_body, body, length, authenticated=authenticated) try: return json.loads(read_all(response)) except ValueError: @@ -668,8 +672,14 @@ def run_import(args): def run_status(args): - """Print the daemon's status, one "name: value" per line.""" - print_fields(client(args).get("/api/status")) + """Print the daemon's status, one "name: value" per line. Without a readable API token (a health check by + another user), only the version the daemon says without it.""" + api = client(args) + if not os.access(api.token_file, os.R_OK): + print_fields(api.call("GET", "/api/status", authenticated=False)) + warn(f"no readable API token at {api.token_file}: the daemon only says its version without it") + return + print_fields(api.get("/api/status")) # Parser diff --git a/tests/security/test_new7_api_status_open.py b/tests/security/test_new7_api_status_open.py new file mode 100644 index 0000000..ad60e84 --- /dev/null +++ b/tests/security/test_new7_api_status_open.py @@ -0,0 +1,117 @@ +"""NEW-7: GET /api/status needed no token and returned the node's identifiers and traffic counters. + +Status stays open so health checks and the CLI's proof check (verify_daemon, which only reads the +X-Rnfs-Proof header) work before the token is sent. But the open response was the whole status document: +node_id, identity_hash, lxmf_address and the traffic counters, to any local process of any user, while +/api/lxmf/address, which returns the same LXMF address, needs the token. Without the token the fixed API +answers only {"version"}; the full document needs the token, which `rnfs status` now sends. + +The API tests run a real ApiServer over a real offline node (its LXMF network replaced by a stand-in holding +only an address, as an offline node has none); the CLI tests reuse tests.test_cli's CliTestCase. +""" +import hmac +import json +import os +import tempfile +import types +import unittest + +import RNS + +from rnfs import __version__, cli +from rnfs.api import ApiServer +from rnfs.config import Config +from rnfs.node import RnfsNode +from tests.test_api import raw_request +from tests.test_cli import CliTestCase +from tests.test_names import FakeNetwork +from tests.test_node import NetClient + +LXMF_ADDRESS = bytes([0x5C] * 16) +PRIVATE_FIELDS = ("node_id", "identity_hash", "lxmf_address", "bytes_sent", "bytes_received", "pins", "records") + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class ApiStatusTest(unittest.TestCase): + """GET /api/status on a real ApiServer and offline node.""" + + def setUp(self): + """An offline node with a stand-in LXMF address, behind an ApiServer.""" + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.node = RnfsNode.offline(Config(rnfs_dir=tmp.name, slow="no"), bytes([0xA] * 16), NetClient(), + FakeNetwork()) + self.addCleanup(self.node.stop) + lxmf = self.node.features.get("lxmf") + if lxmf is not None: + lxmf.network = types.SimpleNamespace(address=LXMF_ADDRESS) + self.server = ApiServer(self.node, 0) + self.server.start() + self.addCleanup(self.server.stop) + + def status(self, headers=None): + """(HTTP status, response headers, JSON body) of GET /api/status with these headers.""" + code, response_headers, body = raw_request(self.server.port, "GET", "/api/status", None, headers or {}) + return code, response_headers, json.loads(body) + + def test_without_the_token_only_the_version(self): + """A local process without the token learns that a daemon runs and its version, nothing else.""" + code, _, body = self.status() + self.assertEqual(code, 200) # health checks still work + # NEW-7: fails until the open status answers only the version + leaked = {field: body[field] for field in PRIVATE_FIELDS if field in body} + self.assertEqual(leaked, {}, "GET /api/status without the token returned the node's private fields") + self.assertEqual(body, {"version": __version__}) + + def test_with_the_token_the_full_document(self): + """With the token, status is the node's whole summary, as before.""" + code, _, body = self.status({"X-Rnfs-Api": self.server.token}) + self.assertEqual(code, 200) + self.assertEqual(body["node_id"], self.node.node_id.hex()) + self.assertIn("records", body) + if self.node.features.get("lxmf") is not None: + self.assertEqual(body.get("lxmf_address"), LXMF_ADDRESS.hex()) + + def test_wrong_token_gets_the_open_answer(self): + """A wrong token is treated as none: only the version.""" + _, _, body = self.status({"X-Rnfs-Api": self.server.token + "x"}) + self.assertEqual(body, {"version": __version__}) + + def test_proof_still_answered_without_the_token(self): + """The CLI's proof check uses the open status: its proof header doesn't need the token.""" + challenge = "ab" * 16 + _, headers, _ = self.status({cli.CHALLENGE_HEADER: challenge}) + expected = cli.api_proof(self.server.token, self.server.port, challenge) + self.assertTrue(hmac.compare_digest(headers[cli.PROOF_HEADER], expected)) + + +class CliStatusTest(CliTestCase): + """`rnfs status` against a FakeNode's ApiServer.""" + + def test_status_sends_the_token_and_prints_everything(self): + """`rnfs status` sends the token (after the daemon's proof), so it still prints the node's fields.""" + out = self.ok("status") + self.assertIn(f"node_id: {self.node.node_id.hex()}", out) + + def test_status_without_a_token_prints_the_version(self): + """Without a readable token, `rnfs status` still works as a health check, and says why it is short.""" + os.unlink(self.token_file) + code, out, err = self.run_cli("status") + self.assertEqual(code, 0, err) + self.assertEqual(out.decode(), f"version: {__version__}\n") + self.assertIn("no readable API token", err) + + +if __name__ == "__main__": + unittest.main() From 9544fd614ca27496a1d7b56a49d55b3d33cd30e3 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:26 +1030 Subject: [PATCH 059/107] Mark sealed files in pin ls --- rnfs/cli.py | 2 +- rnfs/node.py | 22 ++++- tests/security/test_m6_pin_ls_sealed.py | 114 ++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 tests/security/test_m6_pin_ls_sealed.py diff --git a/rnfs/cli.py b/rnfs/cli.py index d5890ee..f73c2c0 100644 --- a/rnfs/cli.py +++ b/rnfs/cli.py @@ -529,7 +529,7 @@ def run_pin_ls(args): print(json.dumps(pins, indent=2)) return for pin in pins: - state = "" if pin["complete"] else " (incomplete)" + state = ("" if pin["complete"] else " (incomplete)") + (" (sealed)" if pin.get("sealed") else "") name = f" {display_name(pin['name'])}" if pin.get("name") else "" print(f"{printable(pin['cid'])} {','.join(printable(s) for s in pin['sources'])}{state}{name}") diff --git a/rnfs/node.py b/rnfs/node.py index 45c61c0..b7446fa 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -26,7 +26,7 @@ from rnfs import __version__, features, fsutil from rnfs.config import Config from rnfs.constants import APP_NAME, DEFAULT_BOOTSTRAP_NODES, DHT_ASPECT, MODE_SERVER, PARTIAL_SUFFIX -from rnfs.content import archive, builder, dag, edit, walker +from rnfs.content import archive, builder, dag, edit, seal, walker from rnfs.content.cid import Cid from rnfs import node_content # --- content: add --no-pin/--to/--chunker, sealed files, gateway formats --- from rnfs.content import chunker as content_chunker @@ -882,10 +882,14 @@ def pin_rm(self, cid: str): self.publisher.refresh() def pin_ls(self, cid: str | None = None) -> list[dict]: - """Pins with their sources, whether they're complete, their type and a local name when one is known. + """Pins with their sources, whether they're complete, their type, whether they're sealed files and a + local name when one is known. The name is where the pin was added from on this machine (pin_names), the shared-folder entry it - came from, or for a stored directory its single entry's name (add --wrap) or its entry count. + came from, or for a stored directory its single entry's name (add --wrap) or its entry count. For a + sealed file it is still the local path or the name given to add (this node's own record, for its user): + `sealed` tells callers that show pins to others not to show the name or the content. `sealed` is None + when it can't be told without fetching (the root or the file's first chunk isn't stored). """ pins = self.storage.pins() self.pin_names.prune(set(pins)) @@ -898,9 +902,19 @@ def pin_ls(self, cid: str | None = None) -> list[dict]: kind, inside = self._pin_shape(c) name = self.pin_names.get(c) or (folder_names.get(c) if FOLDER in sources else None) or inside result.append({"cid": str(c), "sources": sorted(sources), "complete": self.storage.is_complete(c), - "type": kind, "name": name}) + "type": kind, "sealed": self._pin_sealed(c, kind), "name": name}) return result + def _pin_sealed(self, cid: Cid, kind: str | None) -> bool | None: + """True if a pinned file is sealed (its first bytes carry the sealed-file magic), False if it isn't or + is a directory, None if that isn't known from stored blocks. Never fetches.""" + if kind == "directory": + return False + if kind is None: + return None + head = node_content.stored_head(self, str(cid), len(seal.MAGIC)) + return None if head is None else seal.is_sealed(head) + def _pin_shape(self, cid: Cid) -> tuple[str | None, str | None]: """(type, name from inside) of a pin whose root block is stored: "file" or "directory", and for a directory its one entry's name or "(directory: N entries)". (None, None) if the root isn't stored.""" diff --git a/tests/security/test_m6_pin_ls_sealed.py b/tests/security/test_m6_pin_ls_sealed.py new file mode 100644 index 0000000..aef1086 --- /dev/null +++ b/tests/security/test_m6_pin_ls_sealed.py @@ -0,0 +1,114 @@ +"""M6 (core): `pin ls` didn't say which pins are sealed files, so callers that show pins to others couldn't +tell which names and contents to keep back. + +A sealed add records the plaintext path or seal_name as the pin's local name (pin_names), which is useful to +the node's own user and stays local; the leak was companion pages publishing it. Without a marker, a page had +to read each file's first bytes itself to tell. The fixed pin_ls() gives every pin "sealed": True for a file +with the sealed-file magic, False for other files and directories, None when the stored blocks can't tell +(the root or the first chunk isn't stored); `rnfs pin ls` prints "(sealed)" too, and --json carries the +field. The node is an offline node with an RNS identity (so it can seal to itself); the CLI part reuses +tests.test_cli's CliTestCase, whose FakeNode's pin_ls is given these entries. +""" +import io +import json +import os +import tempfile +import unittest + +import RNS + +from rnfs.content.cid import Cid +from tests.security import _pages_helpers as helpers +from tests.test_cli import CliTestCase + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class PinLsSealedTest(unittest.TestCase): + """pin_ls() of sealed and plain pins on an offline node.""" + + def setUp(self): + """One offline node with an identity, and a provider it can fetch from.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.provider, self.node = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.provider.stop) + self.addCleanup(self.node.stop) + helpers.give_identity(self.node) + + def entry(self, cid: str) -> dict: + """cid's pin_ls() entry.""" + return self.node.pin_ls(cid)[0] + + def test_sealed_pins_are_marked(self): + """Sealed files (from a path or stdin) are marked sealed; the local name stays for the local user.""" + path = os.path.join(self.tmp.name, "Q3-layoffs.xlsx") + with open(path, "wb") as f: + f.write(b"secret data" * 10) + from_path = self.node.add_path(path, to_self=True)["cid"] + from_stdin = self.node.add_stream(io.BytesIO(b"minutes"), to_self=True, seal_name="board-minutes.txt")["cid"] + # M6: fails until pin_ls marks sealed files + self.assertEqual(self.entry(from_path).get("sealed"), True, "pin ls doesn't mark a sealed file") + self.assertEqual(self.entry(from_stdin).get("sealed"), True) + self.assertEqual(self.entry(from_path)["name"], os.path.abspath(path)) # kept, local only + + def test_plain_pins_are_not_marked(self): + """Plain files, tiny files and directories are marked not sealed.""" + plain = self.node.add_stream(io.BytesIO(b"plain notes" * 100))["cid"] + tiny = self.node.add_stream(io.BytesIO(b"rnfs"))["cid"] # shorter than the magic + folder = os.path.join(self.tmp.name, "maps") + os.makedirs(folder) + with open(os.path.join(folder, "north.png"), "wb") as f: + f.write(b"png") + directory = self.node.add_path(folder)["cid"] + # M6: fails until pin_ls has the sealed field + self.assertEqual([self.entry(cid).get("sealed", "missing") for cid in (plain, tiny, directory)], + [False, False, False]) + + def test_unknown_when_the_first_chunk_isnt_stored(self): + """A pin whose file content isn't stored yet (a remote pin still fetching) is None, not False.""" + cid = self.provider.add_stream(io.BytesIO(os.urandom(300_000)))["cid"] # several chunks + root = Cid.parse(cid) + self.node.storage.put(root, self.provider.storage.get(root)) # the root block only + self.node.storage.pin(root, "remote:" + "ab" * 16) + self.assertEqual(self.entry(cid)["type"], "file") + # M6: fails until pin_ls says when it can't tell + self.assertIsNone(self.entry(cid).get("sealed", "missing")) + + +class PinLsCliTest(CliTestCase): + """`rnfs pin ls` output for sealed entries.""" + + def setUp(self): + """A FakeNode whose pin_ls returns a sealed and a plain entry.""" + super().setUp() + entries = [{"cid": "b" + "a" * 60, "sources": ["user"], "complete": True, "type": "file", "sealed": True, + "name": "/home/me/Q3-layoffs.xlsx"}, + {"cid": "b" + "c" * 60, "sources": ["user"], "complete": True, "type": "file", "sealed": False, + "name": "/home/me/notes.txt"}] + self.node.pin_ls = lambda cid=None: entries + + def test_text_output_marks_sealed(self): + """The text listing says which pins are sealed.""" + lines = self.ok("pin", "ls").splitlines() + self.assertIn("(sealed)", lines[0]) + self.assertNotIn("(sealed)", lines[1]) + + def test_json_output_carries_the_field(self): + """--json passes "sealed" through for scripts and pages.""" + pins = json.loads(self.ok("pin", "ls", "--json")) + self.assertEqual([pin["sealed"] for pin in pins], [True, False]) + + +if __name__ == "__main__": + unittest.main() From c14acc7e5678b573399ef91d49fbb424f277f6a1 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:30 +1030 Subject: [PATCH 060/107] Document the remote pin, private mode, /provide, status and pin ls fixes, and what the roots strategy reveals --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ README.md | 23 ++++++++++++++++------- design.md | 40 ++++++++++++++++++++++++++++------------ specification.md | 34 ++++++++++++++++++++++++---------- 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993ecc2..2d274f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,26 @@ Fixes: the output fits, so a `get` could fill the disk. - Names given as text must be exactly 32 hex digits (optionally in ``, with spaces around): whitespace between the digits used to be skipped, giving a name shorter than 16 bytes. +- A remote `/pin` accepted just as the previous one of that identity finished was answered "accepted" and + never fetched (sending it again didn't help): the fetch thread now deregisters in the same step that finds + nothing left. If the thread can't be started the `/pin` is refused, where the identity used to be stuck + until a restart, its pins queuing up and then being refused. +- Remote pins accepted but not yet fetched survive a restart: they are saved in `state/remote_pins.json` and + fetched again at start, unless their identity has left `remote_pin_identities`. They used to be dropped + silently. + +Local API and privacy: +- `GET /api/status` without the API token answers only `{"version": ...}`, enough for health checks; the node + ID, identity hash, LXMF address and traffic counters need the token, as `/api/lxmf/address` already did. + `rnfs status` sends the token (without a readable one it prints just the version and says why). +- `pin ls` says which pins are sealed files: `(sealed)` in the listing and `"sealed": true` in `--json` + (`false` for other files and directories, `null` when the stored blocks can't tell). The local name of a + sealed add is still the path or name you gave, kept on your machine only; anything that shows pins to + others must leave sealed entries out. +- The README and specification now say plainly that under the default `provide_strategy = "roots"` every + complete download, `cat` and `stat` of other nodes' content included, is provided and served, so anyone + can learn what the node fetched, and that `"pinned"` avoids it; and that scripts showing content to others + must use `cat --raw`, since `cat` decrypts sealed files this node can open. Behaviour is unchanged. Gateway privacy: - Errors from the node other than not-found (`400`, `413`, `422`, `503`, `507`, as well as `500`) get a fixed @@ -72,6 +92,14 @@ DHT and names: providing content reveals the provider in IPFS. Behaviour is unchanged. - A `/provide` carrying more than 55 keys (what fits `MAX_REQUEST_SIZE`) is refused as malformed. rnfs's own client never sends more. +- A `/provide` from a publisher already holding its share of the provider store is refused cheaply: its + expired records are looked for at most once per batch, and not at all while none can have expired. Each + refused key used to rescan all of the publisher's records (about 5 ms of work per request under the + store's lock at the default size). +- Private mode drops the provider records of non-members when the node starts (those stored while it was + public, or by a member since removed from `members`), and `/find` replies leave out any non-member's + record; such records used to be handed to members for up to a week. A private node also links only to + members, so it never identifies itself to an outsider. ## 0.1.3 diff --git a/README.md b/README.md index 1fb19d0..189a35d 100644 --- a/README.md +++ b/README.md @@ -463,7 +463,9 @@ rnfs add report.pdf --wrap # in a directory, so the link keeps its name: ` stops sharing a file, -and `rnfs gc` frees space used by downloads you didn't pin. +and `rnfs gc` frees space used by downloads you didn't pin. By default your node also shares what you +download (`get`, `cat`, even `stat` of a small file) while it's cached, so others can see what you fetched; +set `provide_strategy = "pinned"` to share only what you pin. ### 5. Names: a link that can change @@ -497,7 +499,7 @@ only makes sense on IP networks. It is **not** compatible with IPFS. The full co | Bitswap | `/blocks` requests over Reticulum Resources. Downloads are multi-source, resumable and verified. Providers are ranked by seniority, then hops and link speed. Responses are sized to the link's measured rate. | | IPNS | Names are Reticulum announces with a signed record. **Keepers** (any node, with `name keep`) store and re-serve a name's signed record, so it outlives its owner's announces. | | Pinning, remote pinning, GC | The same, with pin sources (user, folder, name, remote, follow, LXMF). `add --no-pin` adds a file as evictable cache. | -| Reprovider strategies | `roots` (default: what you added or pinned, plus your complete downloads while cached) or `pinned` (only what you added or pinned). | +| Reprovider strategies | `roots` (default: what you added or pinned, plus your complete downloads while cached, which anyone can then see) or `pinned` (only what you added or pinned). | | CAR files | rnfs archives: `export` / `import`. | | HTTP gateway | Local gateway with sandboxed pages, plus trustless `?format=raw` and `?format=archive`. | | MFS | `dir set` / `dir rm` make edited copies of a directory; use a name for the mutable root. | @@ -540,7 +542,7 @@ lxmf_auto_fetch_from = [""] | `announce_interval` | `21600` (6 h) | How often the node (and its names) announce; keep it ≥ 1 h | | `startup_announce_delay` | `30` | Second announce after startup | | `republish_interval` | 24 h; 3 days on slow nodes | How often provider records are republished; must be under 3.5 days | -| `provide_strategy` | `"roots"` | `"roots"`: share pins and complete downloads while they're cached (up to `max_storage`); `"pinned"`: share only pins, so downloads stay private | +| `provide_strategy` | `"roots"` | `"roots"`: share pins and every complete download while it's cached (up to `max_storage`), whatever fetched it: `get`, `cat`, even a `stat` of a small file. The node is then listed as a provider and serves those blocks, so **anyone can learn what it downloaded**. `"pinned"`: share only pins, so downloads stay private | | `provider_seniority` | `86400` (1 day) | Providers and name keepers known this long are tried first; publishers seen this long get their full share of a full provider store | | `lookup_timeout` | `600` | Time budget of a DHT lookup | | `path_timeout` | `30` | Wait for a Reticulum path | @@ -559,7 +561,7 @@ lxmf_auto_fetch_from = [""] | `max_download_size` | none | Refuse fetches larger than this | | `max_output_entries` | `100000` | Most directory entries a listing, `get` or export may write | | `scan_interval` | `60` | How often the shared folder `files/` is scanned | -| `remote_pin_identities` | `[]` | Identity hashes allowed to ask this node to pin (`pin remote add`); a node's is the `identity_hash` in its `rnfs status` | +| `remote_pin_identities` | `[]` | Identity hashes allowed to ask this node to pin (`pin remote add`); a node's is the `identity_hash` in its `rnfs status`. Pins accepted and not yet fetched are resumed after a restart | | `remote_pin_quota` | `1073741824` (1 GiB) | Storage each of those identities may use | **Transfers** @@ -624,7 +626,7 @@ The inbox keeps at most 500 messages (4 MiB in all), and at most 50 from any one | Setting | Default | Description | |---|---|---| -| `private` | `false` | Private mode: every link identifies and only `members` are served (`daemon --private`) | +| `private` | `false` | Private mode: every link identifies, links go only to `members`, only they are served, and only their provider records are kept and handed out (`daemon --private`) | | `members` | `[]` | Identity hashes of the members. `rnfs status` shows a node's `identity_hash` (so does `rnid -i ~/.rnfs/storage/identity`) | ## Commands @@ -647,7 +649,7 @@ node ID is expected, a node contact's nick works too. | Command | Description | |---|---| | `rnfs daemon [--bootstrap NODE_ID…] [--rnsconfig DIR] [--gateway-port N] [--dht-mode server\|client] [--slow yes\|no\|auto] [--private] [--provide-strategy pinned\|roots] [--no-gateway-scripts] [-v\|-vv]` | Run a node with its API and gateway | -| `rnfs status` | Node ID, identity hash (what others list in `members` and `remote_pin_identities`), known nodes, records, storage, traffic, jobs, LXMF address | +| `rnfs status` | Node ID, identity hash (what others list in `members` and `remote_pin_identities`), known nodes, records, storage, traffic, jobs, LXMF address. Without a readable API token (another user's health check) only the daemon's version | | `rnfs peers [--json]` | Known nodes: DHT mode, seniority, hops, interface, link rate, last heard, traffic, bans | | `rnfs stats [--json]` | Traffic since start: totals, uploads refused by your limits, per interface | | `rnfs ping NODE_ID` | Round-trip time to a node | @@ -689,6 +691,10 @@ interrupted transfer never leaves a half-written file under the real name. Names printed and logged with control characters escaped. `get`/`cat --raw` give a sealed file's stored (encrypted) bytes. `cat --block` gives a CID's own block. +`cat` and `get` decrypt sealed files this node can open, so scripts and pages that show content to other +people must use `cat --raw` (and refuse what starts with `rnfs-sealed`), or skip pins that `pin ls --json` +marks `"sealed": true`. Under the default `provide_strategy`, anything such a script reads completely is +also shared from your node (see `provide_strategy`). **Pins and storage** @@ -696,7 +702,7 @@ printed and logged with control characters escaped. |---|---| | `rnfs pin add TARGET [--background]` | Fetch completely and pin (share) | | `rnfs pin rm CID` | Remove your pin | -| `rnfs pin ls [CID] [--json]` | Pins, their sources (`user`, `folder`, `name:…`, `remote:…`, `follow:…`, `lxmf:…`) and where each came from: the local path you added it from (`~/notes.txt`, `(stdin)`, `archive …`, the target given to `pin add`), the shared-folder file name, or for a directory its single entry's name or entry count. These names are kept only on your machine; pins made before this was added show none for plain files | +| `rnfs pin ls [CID] [--json]` | Pins, their sources (`user`, `folder`, `name:…`, `remote:…`, `follow:…`, `lxmf:…`), whether each is a sealed file (`(sealed)`; `"sealed": true`, `false`, or `null` if not stored enough to tell) and where each came from: the local path you added it from (`~/notes.txt`, `(stdin)`, `archive …`, the target given to `pin add`), the shared-folder file name, or for a directory its single entry's name or entry count. These names are kept only on your machine, sealed files' too (their sealed name is what others can't read): anything that publishes a pin list must leave sealed entries out; pins made before this was added show none for plain files | | `rnfs pin remote add NODE_ID CID` / `rnfs pin remote rm NODE_ID CID` | Ask another node to pin or unpin (it must list your identity in `remote_pin_identities`) | | `rnfs gc` | Evict every cached download | | `rnfs verify` | Re-check every stored block against its CID | @@ -885,5 +891,8 @@ ery thread's stack. forged. - Sealed files hide content and file name, not size or recipients, and don't prove who sealed them. `get` of a directory leaves the sealed files inside it encrypted. +- With the default `provide_strategy = "roots"`, every complete download is provided to the DHT and served, + including `cat` and `stat` reads of other nodes' content: anyone can learn what your node fetched. Use + `"pinned"` to keep downloads private. - LXMF: messages over 256 kB are refused, and unsent messages are lost if the daemon restarts. Offline delivery and downloads need `lxmf_propagation_node` (or `--nearest` each time). diff --git a/design.md b/design.md index 99d40db..850d43e 100644 --- a/design.md +++ b/design.md @@ -153,11 +153,14 @@ min_age) -> int` (0 senior and healthy, 1 healthy, 2 stale, 3 absent: ranks keep `node_count()`. `providers.py` — `ProviderStore(max_records, state_path, ttl=PROVIDER_TTL, clock=time.time, rng=random, -own_id=None, seniority=0)`: `store(key, publisher) -> bool`, `withdraw(key, publisher)`, -`for_reply(key, exclude=lambda id: False) -> list[bytes]`, `expire()`, `record_count()`, `allowance(publisher)`, -`save()`. Local limits against flooding: one publisher holds at most max(10, `max_records` // 100) records once -seen for `seniority` (the node passes `provider_seniority`) and a tenth of that (at least 10) before, the -node's own (`own_id`) excepted; a key keeps at most 1000 publishers: a newcomer to a full key evicts a random +own_id=None, seniority=0)`: `store(key, publisher) -> bool`, `withdraw(key, publisher)`, `provide(publisher, +keys, withdrawn) -> int`, `retain(keep) -> int` (drops every record of publishers `keep` rejects: private mode's +non-members), `for_reply(key, exclude=lambda id: False) -> list[bytes]`, `expire()`, `record_count()`, +`allowance(publisher)`, `save()`. Local limits against flooding: one publisher holds at most max(10, +`max_records` // 100) records once seen for `seniority` (the node passes `provider_seniority`) and a tenth of +that (at least 10) before, the node's own (`own_id`) excepted. A publisher at its limit has its expired records +swept at most once per batch, and not at all before `sweep_after` (the earliest its oldest record can expire, +from its last sweep), so each further key of a refused `/provide` costs one check; a key keeps at most 1000 publishers: a newcomer to a full key evicts a random one, never one of the `K/2` oldest records. A tenth of the store is a newcomer pool with random eviction: the pooled records sit in a list for O(1) random picks, and the newest publisher with records outside the pool comes from a heap of (first seen, arrival) with stale entries dropped lazily. First-seen times of departed publishers @@ -178,6 +181,10 @@ provider, so the keeper search can wait for providers listed by more than one so `is_dht_server() -> bool`, `find(key) -> (providers, nodes)`, `provide(publisher_id, keys, withdrawn) -> bool`, `served_block(cid) -> bytes | None` (only blocks in the DAG of a currently provided CID, re-verified), `remote_pin(identity_hash, cid, pin) -> bool`, `is_member(identity_hash) -> bool` (private mode). +`NodeBackend.find` leaves out providers `RnfsNode.excluded_provider` rejects: banned, and in private mode +non-members (`_is_member_publisher`: this node, a publisher whose `/provide` the server let through as a member +this run, or a node ID whose recalled identity is a member, as routing admission checks). In private mode the +node also drops non-members' records at start (`ProviderStore.retain`). `client.py` — `PeerClient(links, config)`: `find(node_id, key) -> (providers, nodes) | None`, `provide(node_id, keys, withdrawn) -> set[bytes]` (identified @@ -186,8 +193,8 @@ first refused or unanswered one), `blocks(node_id, cids, on_progress=None) -> Bl FAILED / STALLED; blocks verified against their CIDs; `.blocks: dict[Cid, bytes]`, `.missing` answered None, `.unsent` cut by the budget, `.bad` hash mismatches), `pin(node_id, cid, pin) -> bool`, `ping(node_id) -> float | None`, `has_link(node_id)`, `close(node_id)`. `links.py` links only to the `rnfs.dht` destination of the identity -RNS recalls, and only if its hash is the node ID asked for; the idle sweep spares a link `LinkPool.get()` handed -out within `link_idle_timeout`. +RNS recalls, and only if its hash is the node ID asked for (in private mode, also only if that identity is on +`members`); the idle sweep spares a link `LinkPool.get()` handed out within `link_idle_timeout`. `announces.py` — `DhtAnnouncer(destination, config)` sets `app_data` to the mode and runs the announce schedule; `DhtAnnounceHandler(on_server, on_client, is_banned)`. @@ -213,7 +220,7 @@ out within `link_idle_timeout`. | `dir_set(target, name, child_cid, executable=False)`, `dir_rm(target)` | `{"cid"}`, pinned `user` | | `pin_add(target)` | `{"cid"}` once complete | | `pin_rm(cid)` | `None`; NotFound if no `user` source | -| `pin_ls(cid=None)` | `[{"cid", "sources": [str], "complete": bool, "type": "file" \| "directory" \| None, "name": str \| None}]`; `type` and `name` only when the root block is stored, `name` where the pin was added from (pin_names), its shared-folder entry, or a directory's single entry or entry count | +| `pin_ls(cid=None)` | `[{"cid", "sources": [str], "complete": bool, "type": "file" \| "directory" \| None, "sealed": bool \| None, "name": str \| None}]`; `type` and `name` only when the root block is stored, `name` where the pin was added from (pin_names; for a sealed add, the local path or the name given, kept for the local user), its shared-folder entry, or a directory's single entry or entry count; `sealed` from the file's first bytes (`seal.MAGIC`), False for directories, None if the root or first chunk isn't stored. Callers that show pins to others must skip `sealed` entries' names and content | | `pin_remote(node_id, cid, pin)` | `bool` accepted | | `gc()` / `verify()` | `{"removed_blocks", "freed_bytes"}` / `{"checked", "bad": [cid]}` | | `providers(cid)` | `[node_id bytes]` | @@ -224,7 +231,7 @@ out within `link_idle_timeout`. | `name_publish(alias, target)` | `{"name", "cid", "sequence"}` | | `name_resolve(name, for_gateway=False)` | `{"name", "cid", "sequence", "age"}`; NotFound if unresolved | | `export(target, output)` / `import_archive(path)` | `{"roots", "blocks"}` / `{"roots", "incomplete", "blocks"}` | -| `status()` | `{"version", "node_id", "identity_hash", "dht_mode", "slow", "private", "known_nodes", "records", "pins", "provided", "blocks", "used_bytes", "max_storage", "names"}` plus every feature's `status()` fields (traffic, jobs, LXMF); `identity_hash` is the RNS identity hash (None on offline test nodes) | +| `status()` | (API: without the token, only `{"version"}`) `{"version", "node_id", "identity_hash", "dht_mode", "slow", "private", "known_nodes", "records", "pins", "provided", "blocks", "used_bytes", "max_storage", "names"}` plus every feature's `status()` fields (traffic, jobs, LXMF); `identity_hash` is the RNS identity hash (None on offline test nodes) | `RnfsNode.offline(config, node_id, client, names_network)` builds a node without Reticulum or background threads, for tests (tests/test_node.py). The request server's backend is `node.backend` (`NodeBackend`). @@ -249,7 +256,10 @@ are absolute paths on the daemon's machine (the CLI resolves them); stdin is upl `/api/name/import`, `/api/name/publish`, `/api/export`, `/api/import`. `GET /api/info` and `POST /api/get` take an optional `from` (node ID); `GET /api/name/resolve?name=` takes a name or a name contact's nick. -The API requires `X-Rnfs-Api: ` on every request except `GET /api/status`. The daemon writes a new random +The API requires `X-Rnfs-Api: ` on every request except `GET /api/status`, which without a valid token +answers only `{"version"}` (health checks, and `verify_daemon`, which reads only the proof header); the CLI sends +the token with every other request, `rnfs status` included (without a readable token it prints the version +and says why). The daemon writes a new random token to `/api_token` (mode 0600, in the 0700 rnfs dir) at each start and the CLI reads it, so other local users and processes can't drive the API. `_make_private_dirs` refuses to start (RnfsError) if the rnfs dir belongs to another user or can't be made 0700 (not on Windows). A response to a request with @@ -416,7 +426,8 @@ Slow work that runs while a lock is held: Under `/state/`, JSON written atomically from a temp file synced to disk: `pins.json`, `cache.json` (storage); `pin_names.json` (rnfs/pin_names.py: where each pin was added from, local only, pruned to the pin list); `providers.json` (provider records); `published.json` (publisher: last rounds, earlier rounds' nodes, -pending withdrawals); `fetched.json` (targets fetched for the user, for `roots`); `names.json` (sequences, name +pending withdrawals); `fetched.json` (targets fetched for the user, for `roots`); `remote_pins.json` (remote pins accepted and +not yet fetched, `[[source, cid], ...]` in order); `names.json` (sequences, name table); `name-floor.json` (highest sequence of names resolved for the gateway or auto-fetch, at most 4 × `name_table_size`, saved by the re-announce loop when changed); `invalid.json` (invalid roots); `shared_folder.json`; `jobs.json` (results keep at most 100 warnings plus `warnings_total`; older results are capped at load); `keepers.json` (records re-verified at load); @@ -501,7 +512,12 @@ Everything in the specification is implemented, with these simplifications and a - A remote pin's usage is the larger of its content size and its DAG's block bytes. It is dropped once the root block shows the quota would be exceeded, or once the blocks fetched exceed it. A source's remote pins are fetched one at a time, oldest first (`_remote_pin_worker`), so the quota check counts every earlier pin; - at most 64 (`REMOTE_PIN_QUEUE`) may be waiting or fetching, and further `/pin`s are refused. + at most 64 (`REMOTE_PIN_QUEUE`) may be waiting or fetching, and further `/pin`s are refused. A worker leaves + `_remote_workers` in the same critical section in which it finds its queue empty, so a `/pin` arriving as it + exits starts a new one; if `Thread.start()` fails the pin is given back and refused. The pins waiting are + saved to `state/remote_pins.json` by maintenance (when changed) and at stop, and `_resume_remote_pins` queues + them again at start (skipping those already pinned or whose identity left `remote_pin_identities`); workers + wait for the first join before fetching, and a fetch cut short by stopping stays queued. - Byte-range reads (`cat`, gateway) walk the file lazily and fetch 32 chunks at a time ahead of what has been written out. A read may meet at most `max_output_entries` pieces much shorter than `CHUNK_SIZE`, which bounds files built from repeated tiny or empty subtrees without limiting honest large files. diff --git a/specification.md b/specification.md index 1de7246..c8709d8 100644 --- a/specification.md +++ b/specification.md @@ -222,7 +222,12 @@ across shared interfaces and transport nodes, a node MAY run in private mode: - every link identifies; - handlers answer `False` to identities not on the member list (checked inside the handler, since RNS's `ALLOW_LIST` drops refused requests silently); -- the routing table admits only members. +- the routing table admits only members; +- provider records are kept and handed out only for members: at start the node drops the records of every + publisher whose node ID it can't tie to a member identity (stored while it was public, or by a member since + removed from the list), and `/find` replies leave out any such record; +- outgoing links go only to nodes whose identity is a member, so the node never identifies itself to an + outsider (one listed as a provider anywhere, say). ## 4. Content identifiers @@ -510,11 +515,14 @@ and repeat. don't come due at once; the jittered interval stays below `PROVIDER_TTL / 2`. - **Provide strategy** (`provide_strategy`): - `roots` (default) provides every complete pin, and every complete fetched target while it is cached - (so at most `max_storage` of downloads at a time). - - `pinned` provides every complete pin only. + (so at most `max_storage` of downloads at a time). That is every complete download, whatever the command: + `get`, `cat`, and even `stat`, `info` or `ls` of another node's content when what they fetched is the + whole target (a small file is one block). Anyone can look up the provider records, or ask the node for + the blocks (section 7), and so learn what it fetched. + - `pinned` provides every complete pin only, so downloads are never published or served. `roots` helps popular content spread; `pinned` uses less airtime and doesn't publish what a user - downloads. + downloads. Gateway fetches are never provided under either. Nothing else inside a DAG is provided separately: share `/`, or pin the inner CID to provide it. Keepers also provide the keys of the names they keep (section 9), under any strategy. @@ -650,6 +658,9 @@ old pin. fetched one at a time, in the order accepted, so each is checked with the earlier ones counted; a node MAY refuse `/pin` from a source with many pins waiting (rnfs: 64). - The requester learns that a remote pin is complete when the node shows up as a provider (`providers`). +- `/pin` is best effort: an accepted pin may still be dropped (quota, or the CID can't be found). rnfs saves the + pins waiting (`state/remote_pins.json`, about once a second and at stop) and resumes them after a restart, + unless their identity has left `remote_pin_identities`; a crash may lose those accepted in the last second. - `rnfs pin remote add|rm ` sends these requests over an identified link. ## 9. Names @@ -829,8 +840,9 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. **Privacy** (outside private mode, where every link identifies). - Lookups and downloads use unidentified links, so the nodes asked learn only that *some* node wants a key. - Publishing uses a separate identified link, never reused. -- With the default `roots` strategy, complete downloads are published and served while cached, which - reveals them. With `pinned`, downloads are neither published nor served. Gateway fetches never are. +- With the default `roots` strategy, complete downloads (by any command, `cat` and `stat` included) are + published and served while cached, so anyone can learn what the node fetched. With + `pinned`, downloads are neither published nor served. Gateway fetches never are. - DHT keys don't hide which CID is wanted from someone who already knows the CID. - Provider records are public: anyone can look up which node IDs provide a CID, or keep a name. A node's own names are linked to it this way (section 9). @@ -885,11 +897,11 @@ are local conveniences, not part of the protocol. |---|---| | `add \|- [--wrap] [--only-hash] [--name N] [--no-pin] [--to …] [--to-self] [--no-sealed-name] [--chunker fixed\|fastcdc]` | Import a file, a directory or stdin, pin it with source `user`, print its CID. `--wrap` puts it in a directory; `--only-hash` just prints the CID; `--no-pin` keeps it as cache (section 8); `--to` seals it for recipients (section 5, Sealed files); `--chunker fastcdc` uses content-defined chunks (section 5) | | `get [/path] [-o out] [--from ] [--background] [--raw]` | Fetch and write out a file or directory. Without `-o` the output is, in the current directory, the target's last path name if it is printable, not hidden (no leading `.`), doesn't start with `-` (which a later command would read as an option) and is a valid file name here, else its root CID. A sealed file this node is a recipient of is decrypted, and without `-o` (for a bare CID) written under its sealed name by the same rule; `--raw` writes the stored bytes | -| `cat [/path] [--offset N] [--length N] [--raw] [--block]` | Fetch and write a file, or part of one, to stdout; sealed files are decrypted as by `get` (only the segments covering the range); `--block` writes the CID's own block | +| `cat [/path] [--offset N] [--length N] [--raw] [--block]` | Fetch and write a file, or part of one, to stdout; sealed files are decrypted as by `get` (only the segments covering the range); `--raw` writes the stored (encrypted) bytes, which is what scripts that show content to others must use; `--block` writes the CID's own block | | `ls [/path]` | List a directory: name, kind, size, CID | | `stat [/path]` | Size, block count, and how much is held locally | | `dir set [/path] [--exec]`, `dir rm /` | Make a new directory with an entry added, replaced or removed, and pin it with source `user` (fetching the complete directory, file contents included) | -| `pin add [/path] [--background]`, `pin rm `, `pin ls [] [--json]` | Manage pins; `ls` shows their sources and a local name: where each was added from on this machine (recorded locally, never sent), the shared-folder entry, or what a stored directory holds | +| `pin add [/path] [--background]`, `pin rm `, `pin ls [] [--json]` | Manage pins; `ls` shows their sources, whether each is a sealed file (`sealed`: true, false, or null when its stored blocks can't tell), and a local name: where each was added from on this machine (recorded locally, never sent; for a sealed file too), the shared-folder entry, or what a stored directory holds | | `pin remote add\|rm ` | Remote pinning | | `gc`, `verify` | Evict all cached fetches; re-check every stored block against its CID | | `providers ` | Find nodes providing a CID | @@ -903,7 +915,7 @@ are local conveniences, not part of the protocol. | `contacts add [--kind name\|node\|lxmf]`, `contacts rm `, `contacts ls` | Contacts (below) | | `send
[-m text] [--propagate] [--nearest]`, `inbox`, `inbox get [--link K] [-o out]`, `inbox rm \|--all`, `outbox`, `lxmf address`, `lxmf sync [--nearest]` | LXMF (section 13). `--nearest` uses the nearest propagation node heard when `lxmf_propagation_node` isn't set | | `peers [--json]`, `stats [--json]` | Known nodes with hops, interface and traffic; traffic since start, refusals by cause, per interface | -| `status` | Node ID, RNS identity hash (the value other nodes list in `members` and `remote_pin_identities`), known nodes, records held, storage use, traffic, jobs, LXMF address | +| `status` | Node ID, RNS identity hash (the value other nodes list in `members` and `remote_pin_identities`), known nodes, records held, storage use, traffic, jobs, LXMF address (only the version without a readable API token) | Wherever a command takes a target, it may start with a name contact's nick (`get alice/docs/report.pdf`); where it takes a node ID (`get --from`, `ping`, `pin remote`), a node contact's nick works too. @@ -925,7 +937,9 @@ gateway can never reach the API. access. Every local process, of every user, can connect to `127.0.0.1`, and the API reads and writes files as the daemon's user, so only users who can read the rnfs directory may use it. Web pages can't learn the token, and the custom header needs a CORS preflight, which fails: this also blocks cross-site - requests. Status stays open for health checks. + requests. `GET /api/status` stays open for health checks and the proof check below, but without the + token it MUST say no more than the daemon's version (rnfs: `{"version": ...}`); the full status (node ID, + identity hash, LXMF address, traffic) needs the token like every other request. - The daemon MUST refuse to start if the rnfs directory belongs to another user or can't be made accessible to its owner only (outside Windows, where it relies on the ACL inherited from the user's profile). - An API request carrying `X-Rnfs-Challenge: <32 hex digits>` is answered with From 369fe5e62fe27992c1cb20d7a2c8682e69652065 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:46:30 +1030 Subject: [PATCH 061/107] Update existing tests for token-gated status, sealed pin ls entries, member-only links and the roots strategy's documented reads --- tests/security/test_h1_real_rns.py | 7 +++++- tests/security/test_h2_display_page_pins.py | 28 +++++++++++++-------- tests/test_cli.py | 4 ++- tests/test_e2e.py | 9 +++++-- tests/test_links.py | 5 +++- tests/test_node.py | 1 + 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/tests/security/test_h1_real_rns.py b/tests/security/test_h1_real_rns.py index e150f6d..9d1bad6 100644 --- a/tests/security/test_h1_real_rns.py +++ b/tests/security/test_h1_real_rns.py @@ -81,8 +81,13 @@ def _spawn(self, argv, name, env=None): return proc def _status(self): + # With the API token: without it, status says only the version try: - with urllib.request.urlopen(f"http://127.0.0.1:{self.api_port}/api/status", timeout=2) as r: + with open(os.path.join(self.victim_dir, "api_token"), encoding="ascii") as f: + token = f.read().strip() + request = urllib.request.Request(f"http://127.0.0.1:{self.api_port}/api/status", + headers={"X-Rnfs-Api": token}) + with urllib.request.urlopen(request, timeout=2) as r: return json.load(r) except OSError: return None diff --git a/tests/security/test_h2_display_page_pins.py b/tests/security/test_h2_display_page_pins.py index 5ad2c49..503b2ba 100644 --- a/tests/security/test_h2_display_page_pins.py +++ b/tests/security/test_h2_display_page_pins.py @@ -2,7 +2,8 @@ provide content the visitor chose. The CID comes from var_cid; the page ran stat, ls, `pin add` and cat on it with no allowlist and no per-visitor limit. `pin add` pins USER (node.py:821-833), which the node then provides; and with the default provide_strategy="roots", stat/cat alone already make a cached complete fetch -be provided, so dropping only the pin would not stop the seeding. +be provided, so dropping only the pin would not stop the seeding. That part is documented behaviour of +"roots" (kept as the default by the user's decision; "pinned" avoids it), tested as such below. The fixed page shows only the operator's own complete pins (source user or folder) and CIDs in the operator's allowlist file; for anything else it runs nothing but `rnfs pin ls`, which never fetches. Allowlisted CIDs are @@ -102,15 +103,22 @@ def test_page_runs_nothing_that_fetches_a_cid_it_does_not_share(self): self.assertEqual(calls, [["pin", "ls", "--json", self.cid]]) self.assertEqual(self.stored_blocks(self.cid), 0) - # H2 (core): fails while `rnfs stat` / `rnfs cat` of remote content make the node provide it under - # provide_strategy="roots". The page no longer runs them for CIDs it doesn't share (the test above); - # a CLI read that doesn't provide (like the gateway's for_gateway reads) needs a core change. - @unittest.expectedFailure - def test_stat_and_cat_alone_make_the_node_provide(self): - """Removing only the `pin add` isn't enough: with provide_strategy="roots", the page's remaining - stat and cat calls make the complete cached fetch be provided.""" - self.victim.stat(self.cid) # the page's `rnfs stat` - b"".join(self.victim.read(self.cid)) # the page's `rnfs cat` + def test_stat_and_cat_of_remote_content_are_provided_under_roots(self): + """Documented behaviour (specification section 6, "Provide strategy", and section 7, "What a node + serves"; README `provide_strategy`): under the default "roots", every complete download is provided, + including reads through `rnfs stat` and `rnfs cat`, so anyone can learn what the node fetched. That is + why the page runs neither for CIDs it doesn't share (the test above).""" + self.victim.stat(self.cid) # the old page's `rnfs stat` + b"".join(self.victim.read(self.cid)) # the old page's `rnfs cat` + self.assertEqual(self.victim.config.provide_strategy, "roots") + self.assertIn(self.key, self.victim.publisher.provided()) + + def test_pinned_strategy_does_not_provide_reads(self): + """With provide_strategy = "pinned", the same reads are cache that is never provided (section 6).""" + self.victim.config.provide_strategy = "pinned" + self.victim.stat(self.cid) + b"".join(self.victim.read(self.cid)) + self.victim.publisher.refresh() self.assertNotIn(self.key, self.victim.publisher.provided()) def test_contact_pin_is_not_shown(self): diff --git a/tests/test_cli.py b/tests/test_cli.py index 3cf2eac..c46fed9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -301,7 +301,9 @@ def spy(handler): host = f"127.0.0.1:{self.server.port}" self.assertEqual(seen, [("/api/status", host, None, True), # the daemon proves it knows the token first ("/api/gc", host, self.server.token, False), - ("/api/status", host, None, False)]) # status needs no token + # status sends it too, after the proof: without it the daemon only says its version + ("/api/status", host, None, True), + ("/api/status", host, self.server.token, False)]) def test_printed_fields_are_escaped(self): # Whatever the daemon answers is printed with control characters escaped diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c5847f2..96626bf 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -108,9 +108,14 @@ def __init__(self, name: str, base: str, config: str, tcp_port: int, *extra: str self.node_id = wait_for(self.status, 60, f"{name} daemon to start")["node_id"] def status(self) -> dict | None: - """The daemon's status, or None while its API is not up yet.""" + """The daemon's status (with its API token: without it, status says only the version), or None while + its API is not up yet.""" try: - with urllib.request.urlopen(f"http://127.0.0.1:{self.api_port}/api/status", timeout=2) as response: + with open(os.path.join(self.dir, "api_token"), encoding="ascii") as f: + token = f.read().strip() + request = urllib.request.Request(f"http://127.0.0.1:{self.api_port}/api/status", + headers={"X-Rnfs-Api": token}) + with urllib.request.urlopen(request, timeout=2) as response: return json.load(response) except OSError: if self.proc.poll() is not None: diff --git a/tests/test_links.py b/tests/test_links.py index d13bebd..fa7aea5 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -193,7 +193,10 @@ def test_shared_link_does_not_identify(self): self.assertEqual(peer.link.identities, []) def test_private_mode_identifies_every_link(self): - pool = links.LinkPool(self.identity, Config(private=True)) + # Private mode links only to members: NODE's recalled identity is one + member = types.SimpleNamespace(node_id=NODE, hash=bytes([7]) * 16) + links.RNS.Identity = types.SimpleNamespace(recall=lambda node_id: member) + pool = links.LinkPool(self.identity, Config(private=True, members=[member.hash.hex()])) self.addCleanup(pool.close_all) peer = run(self, lambda: pool.get(NODE)) self.assertEqual(peer.link.identities, [self.identity]) diff --git a/tests/test_node.py b/tests/test_node.py index 4166102..bc95b84 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -165,6 +165,7 @@ def test_add_and_read_back(self): self.assertTrue(self.a.is_local(cid)) self.assertFalse(self.a.is_local(str(Cid.for_block(RAW, b"elsewhere")))) self.assertEqual(self.a.pin_ls(), [{"cid": cid, "sources": ["user"], "complete": True, "type": "directory", + "sealed": False, "name": os.path.abspath(os.path.join(self.tmp.name, "tree"))}]) self.assertEqual(self.a.stat(cid)["blocks"], self.a.stat(cid)["local_blocks"]) self.assertIn(Cid.parse(cid), self.a.publisher.provided()) From 6f6b36ed9c53af21227ae97cf940fa89568d6331 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:48:35 +1030 Subject: [PATCH 062/107] Read the DAG nodes eviction and gc walk before taking the storage lock --- CHANGELOG.md | 3 + design.md | 8 +- rnfs/content/storage.py | 193 ++++++++++++++---- .../security/test_new5_storage_lock_walks.py | 123 +++++++++++ 4 files changed, 287 insertions(+), 40 deletions(-) create mode 100644 tests/security/test_new5_storage_lock_walks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9306b05..215467f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,9 @@ Publishing, names and storage: a crash inside `name unfollow` or before the inbox was saved used to leave for good. A followed name's check releases every other target its pin source holds. A pin or unpin whose `pins.json` save fails no longer stays changed in memory. +- Eviction and `gc` read the DAG nodes they walk before taking the storage lock, so serving `/blocks` no longer + waits while the first eviction after a start reads every stored DAG node (about 0.4 s with 20 000 blocks); + what to delete is still decided under the lock, so nothing that became reachable meanwhile is deleted. ## 0.1.3 diff --git a/design.md b/design.md index 654f2d5..d9fd51d 100644 --- a/design.md +++ b/design.md @@ -136,7 +136,13 @@ packaging/ requirements.lock (hash-pinned runtime dependencies), upda (max(256 MiB, 2% of the filesystem)) free. `max_storage` itself never refuses a fetch: it is enforced by eviction once fetches end. - `evict() -> set[Cid]` enforce `max_storage` (LRU cached fetches); `gc() -> stats` evicts every cached fetch; - both delete unreachable blocks and report targets no longer complete, so the node can withdraw them. + both delete unreachable blocks and report targets no longer complete, so the node can withdraw them. Both + first read, without the lock, the DAG nodes they will walk (`_warm`, filling the `_links` and `_steps` + memos; completeness via `dag_blocks`), then decide and delete under the lock from the pins, sessions and + records current then, so `get` (and `/blocks` serving) waits only for that in-memory walk and the deletions, + and a block that became reachable meanwhile is kept. `pins.json` is still written under the lock on every + pin and unpin: a pin must be on disk when `pin()` returns (names' crash consistency and `rnfs pin add` + depend on it), and before a collection can delete what an `unpin()` released. - `verify() -> stats` re-checks every block, deleting bad ones. - A stored DAG node that no longer matches its CID (`DAMAGED`) has unknown children: while anything live reaches one, `evict()` and `gc()` delete nothing and log it once (`_held_back`) until `verify()` removes it or diff --git a/rnfs/content/storage.py b/rnfs/content/storage.py index 743e4d6..50785ab 100644 --- a/rnfs/content/storage.py +++ b/rnfs/content/storage.py @@ -13,7 +13,8 @@ Pins and cache records are persisted as JSON under state_dir: pins at once, cache records (which change on every fetch) by save(), which the node calls periodically and at shutdown. Thread-safe: one lock guards the -indexes; long walks (completeness) run outside it and are discarded if blocks were deleted meanwhile. +indexes; long walks run outside it: completeness walks are discarded if blocks were deleted meanwhile, and +eviction and gc read the DAG nodes they will walk beforehand (_warm), deciding what to delete under the lock. """ import json import os @@ -44,6 +45,7 @@ DISK_RESERVE_FRACTION = 0.02 # ...or this fraction of the filesystem, if larger DAMAGED = object() # Storage._node's answer for a stored DAG node that is unreadable or no longer matches its CID +MISSING = object() # Storage._read_node's answer for a block that isn't stored (or was deleted meanwhile) _IDENTITY_HASH = re.compile(r"[0-9a-f]{32}") @@ -177,6 +179,8 @@ def __init__(self, blocks_dir: str, state_dir: str, max_storage: int): self._cache: OrderedDict[tuple[Cid, tuple[str, ...]], Cid] = OrderedDict() # oldest first self._sessions: list[Session] = [] self._links: dict[Cid, tuple[Cid, ...]] = {} # children of stored DAG nodes; CIDs never change content + # Where a path step leads from a stored DAG node: node -> {name: _step_in's answer}; memoised like _links + self._steps: dict[Cid, dict[str, tuple[Cid, bool] | None]] = {} self._complete: dict[Cid, frozenset[Cid]] = {} # dag_blocks memo, complete DAGs only self._generation = 0 # incremented whenever blocks are deleted, invalidating walks in progress self._cache_dirty = False # cache records changed since the last save() @@ -434,17 +438,47 @@ def _node(self, cid: Cid): children decide what collection keeps.""" if cid.is_raw or cid not in self._sizes: return None + node = self._read_node(cid) + return DAMAGED if node is MISSING else node + + def _read_node(self, cid: Cid): + """The DAG node cid read from disk, needing no lock: as _node, but MISSING if it isn't stored.""" + if cid.is_raw: + return None try: block = self.blocks.get(cid) except OSError: - block = None - if block is None or len(block) > MAX_BLOCK_SIZE or not cid.verify(block): + return DAMAGED + if block is None: + return MISSING + if len(block) > MAX_BLOCK_SIZE or not cid.verify(block): return DAMAGED try: return dag.decode(block) except InvalidDag: return None + @staticmethod + def _listed(node) -> tuple[Cid, ...]: + """The CIDs a decoded DAG node lists; none for None (raw, missing or invalid).""" + if node is None: + return () + if isinstance(node, DirectoryNode): + return tuple(entry.cid for entry in node.entries) + return tuple(child.cid for child in node.children) + + @staticmethod + def _step_in(node, name: str) -> tuple[Cid, bool] | None: + """Where the path step name leads from a decoded node: (entry, True) through a directory, (child, False) + to the index node child that may hold it, or None (no such entry, or not a directory).""" + if isinstance(node, DirectoryNode): + entry = next((e for e in node.entries if e.name == name), None) + return None if entry is None else (entry.cid, True) + if isinstance(node, IndexNode): + candidates = [c for c in node.children if c.first_name <= name] + return (candidates[-1].cid, False) if candidates else None + return None + def _children(self, cid: Cid) -> tuple[Cid, ...] | None: """CIDs listed by a stored DAG node; none for raw, missing or invalid blocks; None if the node is damaged, as its children are unknown (lock held).""" @@ -452,13 +486,7 @@ def _children(self, cid: Cid) -> tuple[Cid, ...] | None: node = self._node(cid) if node is DAMAGED: return None # not memoised: verify() removes it, or a fetch replaces it - if node is None: - listed: tuple[Cid, ...] = () - elif isinstance(node, DirectoryNode): - listed = tuple(entry.cid for entry in node.entries) - else: - listed = tuple(child.cid for child in node.children) - self._links[cid] = listed + self._links[cid] = self._listed(node) return self._links[cid] def _reach(self, roots, known: set[Cid] | frozenset[Cid] = frozenset(), @@ -491,30 +519,85 @@ def _path_cids(self, root: Cid, names: tuple[str, ...], damaged: set[Cid] | None if current.is_raw or current not in self._sizes: return found found.add(current) - node = self._node(current) - if node is DAMAGED and damaged is not None: - damaged.add(current) - if isinstance(node, DirectoryNode): - entry = next((e for e in node.entries if e.name == name), None) - if entry is None: - return found - current = entry.cid - break - if isinstance(node, IndexNode): - candidates = [c for c in node.children if c.first_name <= name] - if not candidates: - return found - current = candidates[-1].cid - continue - return found + step = self._steps.get(current, {}).get(name, MISSING) + if step is MISSING: + node = self._node(current) + if node is DAMAGED: + if damaged is not None: + damaged.add(current) + return found # not memoised, as in _children + step = self._steps.setdefault(current, {})[name] = self._step_in(node, name) + if step is None: + return found + current, through_directory = step + if through_directory: + break # the next name return found + def _warm(self, roots: set[Cid], paths: list[tuple[Cid, tuple[str, ...]]]): + """Read and decode, without the lock, the DAG nodes a collection will walk from roots and along paths, + and memoise what they list (_links, _steps), so the walk under the lock reads (almost) nothing from disk + and /blocks serving isn't held up behind it. + + Only a memo is filled here, never a decision: a CID's content never changes, and only nodes read whole + and matching their CID are memoised (a damaged or missing one is left for the walk under the lock). What + to delete is still decided under the lock, from the pins, sessions and records current then, so a block + that became reachable meanwhile is kept; nodes stored meanwhile are read under the lock. + """ + with self._lock: + links = dict(self._links) + steps = {cid: dict(names) for cid, names in self._steps.items()} + found_links: dict[Cid, tuple[Cid, ...]] = {} + found_steps: dict[tuple[Cid, str], tuple[Cid, bool] | None] = {} + seen: set[Cid] = set() + stack = list(roots) + while stack: + cid = stack.pop() + if cid in seen or cid.is_raw: + continue + seen.add(cid) + listed = links.get(cid) + if listed is None: + node = self._read_node(cid) + if node is MISSING or node is DAMAGED: + continue + listed = found_links[cid] = self._listed(node) + stack.extend(child for child in listed if child not in seen and not child.is_raw) + + def step(cid: Cid, name: str): + """_step_in's answer for cid, memoised or read; MISSING if cid can't be read.""" + known = steps.get(cid, {}).get(name, found_steps.get((cid, name), MISSING)) + if known is MISSING: + node = self._read_node(cid) + if node is MISSING or node is DAMAGED: + return MISSING + known = found_steps[(cid, name)] = self._step_in(node, name) + return known + + for root, names in paths: + current, ended = root, False + for name in names: + while not ended: + result = None if current.is_raw else step(current, name) + if result is None or result is MISSING: + ended = True # the path ends here, or the walk under the lock reads it + else: + current, through_directory = result + if through_directory: + break # the next name + if ended: + break + with self._lock: + for cid, listed in found_links.items(): + if cid in self._sizes: + self._links.setdefault(cid, listed) + for (cid, name), step in found_steps.items(): + if cid in self._sizes: + self._steps.setdefault(cid, {}).setdefault(name, step) + def _live(self, damaged: set[Cid] | None = None) -> set[Cid]: """Every stored block a pin, a session or a cached fetch reaches (lock held).""" - roots = set(self._pins) - for session in self._sessions: - roots |= session.cids - roots |= set(self._cache.values()) + roots = self._roots() | set(self._cache.values()) live = self._reach(roots, damaged=damaged) for root, names in self._cache: live |= self._path_cids(root, names, damaged) @@ -553,6 +636,7 @@ def _delete(self, cids: set[Cid]) -> tuple[set[Cid], int]: deleted.add(cid) freed += self._sizes.pop(cid) self._links.pop(cid, None) + self._steps.pop(cid, None) self._reported_damaged.discard(cid) if deleted: self._used -= freed @@ -583,11 +667,8 @@ def _evict_to(self, limit: int) -> tuple[set[Cid], int, int]: """ if not self._cache and not self._maybe_garbage: return set(), 0, 0 # only pins and sessions hold blocks: there is nothing to evict - roots = set(self._pins) - for session in self._sessions: - roots |= session.cids damaged: set[Cid] = set() - live = self._reach(roots, damaged=damaged) + live = self._reach(self._roots(), damaged=damaged) kept = sum(self._sizes[cid] for cid in live) records = list(self._cache.items()) # oldest first keep = 0 @@ -703,12 +784,24 @@ def evict(self, needed: int = 0) -> set[Cid]: Returns pins and cached targets that were complete and no longer are, for withdrawal. """ + def wanted() -> bool: + """Whether eviction has anything to do (lock held).""" + # With nothing evictable, don't walk everything to find that out again + return self._used + needed > self.max_storage and (bool(self._cache) or self._maybe_garbage) + with self._lock: - if self._used + needed <= self.max_storage: + if not wanted(): return set() - if not self._cache and not self._maybe_garbage: - return set() # nothing is evictable: don't walk everything to find that out again - before = self._complete_sets(self._targets()) + targets = self._targets() + roots = self._roots() | set(self._cache.values()) + paths = [key for key in self._cache if key[1]] + # The long part, reading DAG nodes from disk, runs without the lock (_warm) + before = self._complete_sets(targets) + self._warm(roots, paths) + with self._lock: + if not wanted(): + return set() # another eviction did it meanwhile + before = self._with_memoised(before) limit = self.max_storage - needed - self.max_storage // EVICT_MARGIN deleted, freed, evicted = self._evict_to(max(0, limit)) if deleted: @@ -716,6 +809,22 @@ def evict(self, needed: int = 0) -> set[Cid]: f"in {plural(len(deleted), 'block')}", LOG_VERBOSE) return {cid for cid, blocks in before.items() if blocks & deleted} + def _roots(self) -> set[Cid]: + """What pins and sessions hold (lock held).""" + roots = set(self._pins) + for session in self._sessions: + roots |= session.cids + return roots + + def _with_memoised(self, before: dict[Cid, frozenset[Cid]]) -> dict[Cid, frozenset[Cid]]: + """before (complete DAGs found without the lock) plus the targets memoised as complete now, which a + deletion about to happen may make incomplete (lock held).""" + result = dict(before) + for cid in self._targets(): + if cid in self._complete: + result[cid] = self._complete[cid] + return result + def gc(self) -> dict: """Evict every cached fetch and delete every block no pin or session reaches. @@ -723,7 +832,13 @@ def gc(self) -> dict: "uncached": set[Cid] targets of the evicted fetches}. """ with self._lock: - before = self._complete_sets(self._targets()) + targets = self._targets() + roots = self._roots() + # The long part without the lock, as in evict(); every cached fetch goes, so only pins and sessions count + before = self._complete_sets(targets) + self._warm(roots, []) + with self._lock: + before = self._with_memoised(before) uncached = set(self._cache.values()) if self._cache: self._cache.clear() diff --git a/tests/security/test_new5_storage_lock_walks.py b/tests/security/test_new5_storage_lock_walks.py new file mode 100644 index 0000000..15f89fa --- /dev/null +++ b/tests/security/test_new5_storage_lock_walks.py @@ -0,0 +1,123 @@ +"""NEW-5: evict() and gc() walked DAGs and read blocks from disk while holding the storage lock. + +The first eviction after a restart read and hashed every DAG node under Storage._lock, so /blocks serving +(served_block -> Storage.get) waited behind it (~0.4 s with 20k blocks in the reviewer's run). The tests count the +block reads made while the evicting thread holds the lock, on a storage just reopened (nothing memoised), and +check that what the walk outside the lock finds can't make eviction delete a block that became reachable +meanwhile: a pin and a protection session added from inside that walk are kept. +""" +import os +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs.content import builder +from rnfs.content.dag import KIND_FILE, Entry +from rnfs.content.storage import USER, Storage + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence storage's log.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class LockedWalkTest(unittest.TestCase): + """Directories of small files, half pinned and half cached fetches (some through a path), reopened cold.""" + + def setUp(self): + """12 directories of 12 files; storage reopened so nothing is memoised, with room for about half.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.blocks = os.path.join(self.tmp.name, "blocks") + self.state = os.path.join(self.tmp.name, "state") + storage = Storage(self.blocks, self.state, 10**12) + self.roots = [] + for d in range(12): + entries = [] + for f in range(12): + built = builder.build_bytes(os.urandom(64) + bytes([d, f]), storage.put) + entries.append(Entry(f"f{f:02d}", built.cid, built.size, KIND_FILE)) + self.roots.append(builder.build_directory(entries, storage.put).cid) + for i, root in enumerate(self.roots): + if i % 2: + storage.pin(root, USER) + elif i % 4: + storage.record_fetch(root, [], root) + else: + file = self.entry(storage, root, "f00") + storage.record_fetch(root, ["f00"], file) + storage.record_fetch(root, [], root) + storage.save() + self.used = storage.used_bytes() + self.storage = Storage(self.blocks, self.state, self.used // 2) + + @staticmethod + def entry(storage: Storage, root, name: str): + """The CID of root's entry name.""" + from rnfs.content import dag + return next(e.cid for e in dag.decode(storage.get(root)).entries if e.name == name) + + def reads(self, operation) -> tuple[int, int]: + """(block reads with the storage lock held, reads without it) while operation runs.""" + counts = [0, 0] + real = self.storage.blocks.get + + def get(cid): + """BlockStore.get, counted.""" + counts[0 if self.storage._lock._is_owned() else 1] += 1 + return real(cid) + + with mock.patch.object(self.storage.blocks, "get", get): + operation() + return counts[0], counts[1] + + def test_cold_eviction_reads_no_block_under_the_lock(self): + locked, unlocked = self.reads(self.storage.evict) + self.assertLessEqual(self.storage.used_bytes(), self.storage.max_storage) + # NEW-5: fails until eviction reads the DAG nodes it walks without holding the storage lock + self.assertEqual(locked, 0, f"{locked} block reads with the storage lock held ({unlocked} without)") + + def test_cold_gc_reads_no_block_under_the_lock(self): + locked, unlocked = self.reads(self.storage.gc) + for i, root in enumerate(self.roots): + self.assertEqual(self.storage.is_complete(root), bool(i % 2)) + # NEW-5: fails until gc reads the DAG nodes it walks without holding the storage lock + self.assertEqual(locked, 0, f"{locked} block reads with the storage lock held ({unlocked} without)") + + def test_what_becomes_reachable_during_the_walk_is_kept(self): + """A cached root pinned, and a new block protected by a session, while eviction reads without the lock.""" + cached = self.roots[2] # an evictable cached fetch, among the least recently used + real = self.storage.blocks.get + fired = [] + with self.storage.protect() as session: + def get(cid): + """BlockStore.get; the first read made without the lock pins and stores, as other threads could.""" + if not fired and not self.storage._lock._is_owned(): + fired.append(cid) + self.storage.pin(cached, USER) + session.put(*block) + return real(cid) + + block = (builder.build_bytes(b"fetched meanwhile", lambda cid, data: None).cid, b"fetched meanwhile") + with mock.patch.object(self.storage.blocks, "get", get): + self.storage.evict() + # NEW-5: fails until eviction walks without the lock (on the old code nothing reads without it) + self.assertTrue(fired, "no block was read without the storage lock") + self.assertTrue(self.storage.is_complete(cached), "a DAG pinned during the walk lost blocks") + self.assertTrue(self.storage.has(block[0]), "a block protected during the walk was deleted") + self.storage.gc() # the session still protects its block; the new pin keeps its DAG + self.assertTrue(self.storage.has(block[0])) + self.assertTrue(self.storage.is_complete(cached)) + + +if __name__ == "__main__": + unittest.main() From 2be6acf3a9ddd6133da3e7ab6fa82059941cd362 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:49:26 +1030 Subject: [PATCH 063/107] Retry setting a new file's private access list while Windows reports it in use --- rnfs/fsutil.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rnfs/fsutil.py b/rnfs/fsutil.py index b829156..67ca2e7 100644 --- a/rnfs/fsutil.py +++ b/rnfs/fsutil.py @@ -216,7 +216,9 @@ def write_atomic(path: str, data: bytes, *, mode: int = 0o644, durable: bool = T os.fsync(f.fileno()) # else a power loss can leave path empty once renamed if private and IS_WINDOWS: from rnfs import winacl - winacl.make_private(temp) # by path, once closed: the handle written through can't change its DACL + # By path, once closed (the handle written through can't change its DACL), retried while a virus + # scanner has the new file open + _retrying(winacl.make_private, temp) if exclusive: rename_new(temp, path) else: From ef3cc9df1a57ee4deea0de585f0f0ca488ecb039 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 09:51:32 +1030 Subject: [PATCH 064/107] Start the node even when releasing a stray follow or LXMF pin fails --- rnfs/features/follow.py | 5 ++++- rnfs/features/lxmf.py | 5 ++++- specification.md | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/rnfs/features/follow.py b/rnfs/features/follow.py index f4e5f1a..1cd797b 100644 --- a/rnfs/features/follow.py +++ b/rnfs/features/follow.py @@ -108,7 +108,10 @@ def _release_unfollowed(self): continue if name not in self._pinned: _log(f"releasing {cid}, pinned for name {name.hex()}, which is no longer followed", LOG_WARNING) - self.node.storage.unpin(cid, source) + try: + self.node.storage.unpin(cid, source) + except OSError as e: # e.g. a read-only state directory: tried again at the next start + _log(f"could not release {cid}: {e}", LOG_ERROR) def _save(self): """Write follow.json atomically (lock held).""" diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index cc68815..2f4bc21 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -695,7 +695,10 @@ def _release_unrecorded(self): if source.startswith(LXMF_PREFIX) and (source[len(LXMF_PREFIX):], str(cid)) not in recorded: _log(f"releasing {cid}, pinned for a message from {source[len(LXMF_PREFIX):]} that the inbox " "no longer records", LOG_WARNING) - self.node.storage.unpin(cid, source) + try: + self.node.storage.unpin(cid, source) + except OSError as e: # e.g. a read-only state directory: tried again at the next start + _log(f"could not release {cid}: {e}", LOG_ERROR) @staticmethod def _propagated_sent(entry: dict) -> bool: diff --git a/specification.md b/specification.md index 5bdb459..b65a790 100644 --- a/specification.md +++ b/specification.md @@ -685,7 +685,7 @@ A **name** is a mutable pointer to a CID, owned by a key pair: the IPNS equivale 1. Resolve any path to a CID locally. 2. Pin the target with source `name:`. 3. Set the record with `sequence` one higher than both the publisher's stored sequence and the highest it can - resolve, and persist it. If that fails, the publish fails and the pin of step 2 is released. + resolve, and persist it. If that fails, the publish fails and a pin step 2 added is released. 4. Release that source from the previous target (only now, so a crash leaves the target the persisted record names pinned; any other pin of the source is released at the next start, section 8). 5. Announce, and re-announce every `announce_interval`. From 73041a76ee149701523c76c9a5989f416244bb60 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 10:24:15 +1030 Subject: [PATCH 065/107] Check rnfs.dht responses' msgpack headers before RNS decodes them, and refuse Resources with metadata --- rnfs/network/guards.py | 159 ++++++++++- rnfs/network/protocol.py | 133 ++++++++- tests/security/test_l1_response_precheck.py | 289 ++++++++++++++++++++ tests/security/test_rns_compat_security.py | 112 ++++++++ 4 files changed, 677 insertions(+), 16 deletions(-) create mode 100644 tests/security/test_l1_response_precheck.py diff --git a/rnfs/network/guards.py b/rnfs/network/guards.py index ee2609f..881fb21 100644 --- a/rnfs/network/guards.py +++ b/rnfs/network/guards.py @@ -1,5 +1,6 @@ -"""Guards on RNS: a process-wide one on incoming Resource advertisements (sections 3 and 10: oversized-response -bombs), and one per destination on half-open incoming links (section 3: link exhaustion). +"""Guards on RNS: process-wide ones on incoming Resource advertisements and responses (sections 3 and 10: +oversized-response and decoding bombs), and one per destination on half-open incoming links (section 3: link +exhaustion). RNS checks Destination.set_max_request_size and a request's max_response_size only against an advertisement's data size (d), but receives, buffers and allocates by its transfer size (t). RNS 1.5.6 caps t @@ -12,7 +13,21 @@ - on rnfs.dht links, any multi-segment Resource: the checks above see one segment at a time, so a response of many segments (hundreds of MiB, later read wholly into memory) would pass them, while every legitimate rnfs response fits one. Other destinations' Resources (LXMF's, whose messages may span - segments) are told apart by the link's destination name, which this node chose itself. + segments) are told apart by the link's destination name, which this node chose itself; +- on rnfs.dht links, any Resource carrying metadata: RNS decodes metadata with umsgpack as the Resource + completes, before anyone can look at it, and rnfs responses never have any. + +RNS also decodes a whole response with umsgpack before handing it to rnfs, and a payload of one-byte empty +arrays or maps decodes to some 70 times its size in Python objects (1 MiB: ~70 MB and ~0.3 s of CPU). So on +rnfs.dht links two more RNS methods are wrapped, to check the packed response's msgpack headers first +(protocol.response_refusal, which costs the same for any size) and refuse what can't be an rnfs response: +- Link.response_resource_concluded, for responses sent as a Resource; +- Link.receive, for responses sent as one packet. A packet larger than RNS's default MTU (only possible on a + link that negotiated a larger one, up to hundreds of KiB) is decrypted and checked first, so such a link + decrypts an honest one twice. An rnfs.dht server sends no requests, so a response packet arriving on an + incoming link is dropped unread: RNS would otherwise decode it before finding no request it answers. +A refused response answers its request with None, which every rnfs.dht parser refuses as malformed, so the +requester fails it at once, as it would a malformed response. It also bounds half-open incoming links (section 3, link exhaustion). For every link request, RNS's Destination.incoming_link_request runs Link.validate_request, which does the key exchange, sends a proof, @@ -32,7 +47,8 @@ from rnfs import __version__ from rnfs.constants import APP_NAME, DHT_ASPECT, MAX_RESPONSE_SIZE -from rnfs.log import LOG_ERROR, LOG_WARNING, log, plural +from rnfs.log import LOG_ERROR, LOG_VERBOSE, LOG_WARNING, log, plural +from rnfs.network import protocol # Resource data is a 4-byte random hash plus the payload, encrypted as an RNS Token: 16-byte IV, 32-byte HMAC # and up to 16 bytes of PKCS7 padding. 128 bytes leaves room without letting the transfer outgrow the data. @@ -41,6 +57,8 @@ _lock = threading.Lock() _original_accept = None +_original_response_resource_concluded = None +_original_receive = None _unsupported_reported = False # the error for an RNS whose advertisements lack t and d, logged once per process _ABSENT = object() @@ -86,6 +104,8 @@ def refusal(adv, link=None) -> str | None: return f"transfer size {transfer} exceeds the largest response" if link is not None and is_dht_link(link) and (getattr(adv, "l", 1) != 1 or getattr(adv, "i", 1) != 1): return f"rnfs responses are one segment, not {getattr(adv, 'l', '?')}" + if link is not None and is_dht_link(link) and getattr(adv, "x", False): + return "rnfs responses carry no metadata" return None @@ -119,22 +139,131 @@ def guarded_accept(advertisement_packet, callback=None, progress_callback=None, request_id=request_id) +# Responses checked before RNS decodes them + +PACKET_CHECK_SIZE = RNS.Reticulum.MTU # response packets larger than RNS's default MTU are checked first +REFUSAL_LOG_INTERVAL = 60.0 # seconds between log lines about refused responses, after the first + + +class _ThrottledLog: + """A log line at most every REFUSAL_LOG_INTERVAL seconds, counting the ones left out, so whoever sends + refused responses can't flood the log.""" + + def __init__(self, clock=time.monotonic): + """Nothing logged yet.""" + self.clock = clock + self.lock = threading.Lock() + self.logged_at = None + self.skipped = 0 + + def __call__(self, message: str, level: int): + """Log message at level now, or count it if a line was logged less than REFUSAL_LOG_INTERVAL ago.""" + with self.lock: + now = self.clock() + if self.logged_at is not None and now - self.logged_at < REFUSAL_LOG_INTERVAL: + self.skipped += 1 + return + skipped, self.skipped, self.logged_at = self.skipped, 0, now + log(message + (f" ({plural(skipped, 'more')} since the last)" if skipped else ""), level) + + +_log_refused_response = _ThrottledLog() +_log_unexpected_response = _ThrottledLog() + + +def _answer_as_malformed(link, request_id, size: int): + """Answer the request request_id on link with None, which every rnfs.dht parser refuses as malformed.""" + if request_id is None: + return + try: + link.handle_response(request_id, None, size, size) + except Exception as e: + log(f"error failing a request whose response was refused: {e}", LOG_WARNING) + + +def _read_resource(resource) -> bytes | None: + """A completed response Resource's data (an open file, which RNS reads next), or None if unreadable.""" + data = getattr(resource, "data", None) + try: + packed = data.read() + data.seek(0) + except Exception: + return None + return packed if isinstance(packed, (bytes, bytearray)) else None + + +def guarded_response_resource_concluded(link, resource): + """RNS.Link.response_resource_concluded, but on rnfs.dht links refusing a completed response that can't be + an rnfs response before RNS decodes it.""" + if is_dht_link(link) and getattr(resource, "status", None) == RNS.Resource.COMPLETE: + reason = "rnfs responses carry no metadata" if getattr(resource, "has_metadata", False) else None + packed = _read_resource(resource) if reason is None else None + if packed is not None: + reason = protocol.response_refusal(packed) + if reason is not None: + _log_refused_response(f"refused a response before decoding it: {reason}", LOG_WARNING) + _answer_as_malformed(link, getattr(resource, "request_id", None), + getattr(resource, "total_size", None) or len(packed or b"")) + return None + return _original_response_resource_concluded(link, resource) + + +def guarded_receive(link, packet): + """RNS.Link.receive, but on rnfs.dht links checking a response packet before RNS decodes it.""" + if getattr(packet, "context", None) == RNS.Packet.RESPONSE and is_dht_link(link) \ + and getattr(packet, "packet_type", None) == RNS.Packet.DATA: + if not getattr(link, "initiator", False): + _log_unexpected_response("dropped a response arriving on an incoming link, which has no requests", + LOG_VERBOSE) + return None + data = getattr(packet, "data", b"") + if len(data) > PACKET_CHECK_SIZE: + try: + packed = link.decrypt(data) + except Exception: + packed = None + reason = protocol.response_refusal(packed) if packed is not None else None + if reason is not None: + _log_refused_response(f"refused a response before decoding it: {reason}", LOG_WARNING) + _answer_as_malformed(link, protocol.packed_request_id(packed), len(packed)) + return None + return _original_receive(link, packet) + + +def _wrap(owner, name: str, guard, staticmethod_: bool = False): + """Replace owner.name with guard unless it is already; the original it replaced.""" + current = owner.__dict__.get(name) + if getattr(current, "__func__", current) is guard: + return None + original = getattr(owner, name) + setattr(owner, name, staticmethod(guard) if staticmethod_ else guard) + return original + + def install(): - """Wrap RNS.Resource.accept with the guard. Idempotent; called by RequestServer and LinkPool.""" - global _original_accept + """Wrap RNS.Resource.accept, Link.response_resource_concluded and Link.receive with the guards. Idempotent; + called by RequestServer and LinkPool.""" + global _original_accept, _original_response_resource_concluded, _original_receive with _lock: - current = RNS.Resource.__dict__.get("accept") - function = getattr(current, "__func__", current) - if function is guarded_accept: - return - _original_accept = RNS.Resource.accept - RNS.Resource.accept = staticmethod(guarded_accept) + original = _wrap(RNS.Resource, "accept", guarded_accept, staticmethod_=True) + if original is not None: + _original_accept = original + original = _wrap(RNS.Link, "response_resource_concluded", guarded_response_resource_concluded) + if original is not None: + _original_response_resource_concluded = original + original = _wrap(RNS.Link, "receive", guarded_receive) + if original is not None: + _original_receive = original def installed() -> bool: - """True if the guard is in place.""" - current = RNS.Resource.__dict__.get("accept") - return getattr(current, "__func__", current) is guarded_accept + """True if the guards are in place.""" + def wrapped(owner, name, guard): + current = owner.__dict__.get(name) + return getattr(current, "__func__", current) is guard + return (wrapped(RNS.Resource, "accept", guarded_accept) + and wrapped(RNS.Link, "response_resource_concluded", guarded_response_resource_concluded) + and wrapped(RNS.Link, "receive", guarded_receive)) # Half-open incoming links diff --git a/rnfs/network/protocol.py b/rnfs/network/protocol.py index 4fe12b1..6d2a566 100644 --- a/rnfs/network/protocol.py +++ b/rnfs/network/protocol.py @@ -20,7 +20,7 @@ from rnfs.constants import (BLOCKS_PATH, FIND_PATH, ID_LENGTH, K, KIB, MAX_BLOCK_SIZE, MAX_PROVIDE_KEYS, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE, PIN_PATH, PROVIDE_PATH) -from rnfs.content.cid import Cid +from rnfs.content.cid import RAW, Cid __all__ = ["FIND_PATH", "PROVIDE_PATH", "BLOCKS_PATH", "PIN_PATH"] @@ -254,3 +254,134 @@ def packed_response_size(request_id: bytes, response) -> int: MAX_FIND_RESPONSE_SIZE = packed_response_size(bytes(REQUEST_ID_LENGTH), encode_find_response([bytes(ID_LENGTH)] * K, [bytes(ID_LENGTH)] * K)) MAX_ACCEPT_RESPONSE_SIZE = packed_response_size(bytes(REQUEST_ID_LENGTH), True) # /provide, /pin: True or False + + +def _most_cids_per_request() -> int: + """The most CIDs one /blocks request of at most MAX_REQUEST_SIZE can carry: all of the shortest kind (raw).""" + shortest = Cid.for_block(RAW, b"") + count = 1 + while fits(BLOCKS_PATH, encode_blocks(0, [shortest] * (count + 1)), MAX_REQUEST_SIZE): + count += 1 + return count + + +# So also the most entries a /blocks response can have (26) +MAX_BLOCKS_CIDS = _most_cids_per_request() + + +# Packed responses, checked before RNS decodes them (section 3) +# +# RNS decodes a response with umsgpack before handing it over, and a payload of one-byte empty arrays or maps +# becomes some 70 times its size in Python objects. response_refusal reads only msgpack headers: it builds no +# objects, skips each bin by its length and reads at most a few dozen headers, so it costs the same for a +# response of any size. It accepts every response that one of rnfs.dht's parsers could accept. + +_NIL, _FALSE, _TRUE = 0xC0, 0xC2, 0xC3 +_BIN_WIDTHS = {0xC4: 1, 0xC5: 2, 0xC6: 4} # bin8, bin16, bin32: bytes of length after the code +_ARRAY_WIDTHS = {0xDC: 2, 0xDD: 4} # array16, array32: bytes of count after the code (fixarray: 0x90-0x9f) + + +def _array_at(packed: bytes, position: int) -> tuple[int, int] | None: + """(element count, position after the header) of an array header at position, or None if there's none. + + An array claiming more elements than there are bytes left is refused: every element takes at least one. + """ + if position >= len(packed): + return None + code = packed[position] + if 0x90 <= code <= 0x9F: + count, position = code & 0x0F, position + 1 + elif code in _ARRAY_WIDTHS: + start = position + 1 + _ARRAY_WIDTHS[code] + if start > len(packed): + return None + count, position = int.from_bytes(packed[position + 1:start], "big"), start + else: + return None + return (count, position) if count <= len(packed) - position else None + + +def _bin_at(packed: bytes, position: int) -> tuple[int, int] | None: + """(length, position after its data) of a bin at position, or None if there's none or it overruns.""" + if position >= len(packed) or packed[position] not in _BIN_WIDTHS: + return None + start = position + 1 + _BIN_WIDTHS[packed[position]] + if start > len(packed): + return None + length = int.from_bytes(packed[position + 1:start], "big") + return (length, start + length) if start + length <= len(packed) else None + + +def _find_answer_end(packed: bytes, position: int) -> int | None: + """Position after a /find answer ([[ID, …], [ID, …]], each list at most K long) at position, or None.""" + outer = _array_at(packed, position) + if outer is None or outer[0] != 2: + return None + position = outer[1] + for _ in range(2): + ids = _array_at(packed, position) + if ids is None or ids[0] > K: + return None + position = ids[1] + for _ in range(ids[0]): + item = _bin_at(packed, position) + if item is None or item[0] != ID_LENGTH: + return None + position = item[1] + return position + + +def _entries_end(packed: bytes, position: int) -> tuple[int | None, str | None]: + """(position after, None) for a /blocks answer at position: a list of at most MAX_BLOCKS_CIDS entries, each + a bin of at most MAX_BLOCK_SIZE bytes or nil; else (None, why not).""" + entries = _array_at(packed, position) + if entries is None: + return None, "not a list" + count, position = entries + if count > MAX_BLOCKS_CIDS: + return None, f"{count} entries, more than a request can ask for" + for _ in range(count): + if position < len(packed) and packed[position] == _NIL: + position += 1 + continue + block = _bin_at(packed, position) + if block is None: + return None, "an entry that is neither a block nor None" + if block[0] > MAX_BLOCK_SIZE: + return None, f"a {block[0]}-byte entry, over MAX_BLOCK_SIZE" + position = block[1] + return position, None + + +def response_refusal(packed: bytes) -> str | None: + """Why packed (RNS's response framing, [request_id, response], as received) can't hold any rnfs.dht + response, judged from msgpack headers alone without decoding it; None if it may. + + The response may be True, False or None (a /provide or /pin answer, or a refusal), a /find answer, or a + /blocks answer (also the shape of a /name one). Nothing may follow it. + """ + outer = _array_at(packed, 0) + request_id = _bin_at(packed, outer[1]) if outer is not None and outer[0] == 2 else None + if request_id is None or request_id[0] != REQUEST_ID_LENGTH or request_id[1] >= len(packed): + return "not a response" + position = request_id[1] + if packed[position] in (_NIL, _FALSE, _TRUE): + end = position + 1 + else: + end = _find_answer_end(packed, position) + if end is None: + end, reason = _entries_end(packed, position) + if end is None: + return reason + if end != len(packed): + return f"{len(packed) - end} bytes after the response" + return None + + +def packed_request_id(packed: bytes) -> bytes | None: + """The request ID at the head of packed response framing, read without decoding the rest; None if absent.""" + outer = _array_at(packed, 0) + request_id = _bin_at(packed, outer[1]) if outer is not None and outer[0] == 2 else None + if request_id is None or request_id[0] != REQUEST_ID_LENGTH: + return None + return bytes(packed[request_id[1] - REQUEST_ID_LENGTH:request_id[1]]) diff --git a/tests/security/test_l1_response_precheck.py b/tests/security/test_l1_response_precheck.py new file mode 100644 index 0000000..47e14ea --- /dev/null +++ b/tests/security/test_l1_response_precheck.py @@ -0,0 +1,289 @@ +"""L1: RNS decodes a whole rnfs.dht response with umsgpack before rnfs can check it, so a payload of one-byte +empty arrays (0x90) or maps (0x80) costs some 70 times its size in Python objects. + +Fixed: guards wraps Link.response_resource_concluded and Link.receive so that, on rnfs.dht links, a response's +msgpack headers are checked first (protocol.response_refusal), and refuses Resources with metadata at their +advertisement. These tests run the real RNS methods behind the guards with fake links: every honest response +shape still reaches the requester decoded as before; the bombs are refused with umsgpack.unpackb never called; +and a refused response fails the /blocks request exactly as a malformed response does. +""" +import io +import types +import unittest +from unittest import mock + +import RNS +from RNS.vendor import umsgpack + +from rnfs.config import Config +from rnfs.constants import ID_LENGTH, K, MAX_BLOCK_SIZE, MIB +from rnfs.content.cid import RAW, Cid +from rnfs.network import client, guards, links, protocol +from rnfs.features import keepers + +import tests.test_links as fakes + +REQUEST_ID = bytes(range(16)) +ID = bytes(ID_LENGTH) +DHT = types.SimpleNamespace(name=guards.DHT_NAME + "." + "ab" * 16) +LXMF = types.SimpleNamespace(name="lxmf.delivery." + "ab" * 16) + + +def bomb(size: int, code: int) -> bytes: + """size bytes of response framing whose response is a list of one-byte empty arrays (0x90) or maps (0x80).""" + count = size - 24 + return b"\x92\xc4\x10" + REQUEST_ID + b"\xdd" + count.to_bytes(4, "big") + bytes([code]) * count + + +def honest_responses() -> dict: + """Every shape an honest rnfs.dht response can have, by name.""" + block = bytes(MAX_BLOCK_SIZE) + record = [bytes(keepers.PUBLIC_KEY_LENGTH), keepers.NAME_HASH, bytes(keepers.RANDOM_HASH_LENGTH), + bytes(keepers.RATCHET_LENGTH), bytes(keepers.SIGNATURE_LENGTH), bytes(keepers.MAX_APP_DATA)] + return { + "True (/provide, /pin)": True, + "False (a refusal)": False, + "/find": [[ID] * K, [ID] * K], + "/find, empty": [[], []], + "/blocks, empty": [], + "/blocks, missing": [None], + "/blocks, one largest block": [block], + "/blocks, most entries": [None, block] + [b"x" * 100] * (protocol.MAX_BLOCKS_CIDS - 2), + "/blocks, two blocks": [b"a" * 1000, b"b" * 2000], + "/name": record, + "/name, none kept": [], + } + + +class ResponseLink: + """An rnfs.dht (or other) link as far as Link.response_resource_concluded, receive and handle_response use it: + handle_response records what RNS hands over.""" + + def __init__(self, destination=DHT, initiator=True): + """An active link to destination.""" + self.destination = destination + self.initiator = initiator + self.status = RNS.Link.ACTIVE + self.pending_requests = [] + self.handled = [] + + def handle_response(self, request_id, response_data, response_size, response_transfer_size, metadata=None, + update_sizes=False, check_size=False): + """Record the response RNS hands over.""" + self.handled.append((request_id, response_data)) + + @staticmethod + def decrypt(data): + """No encryption in these tests.""" + return data + + +def completed(packed: bytes, request_id=REQUEST_ID, has_metadata=False): + """A response Resource that has completed with packed, as RNS hands it to response_resource_concluded.""" + return types.SimpleNamespace(status=RNS.Resource.COMPLETE, has_metadata=has_metadata, data=io.BytesIO(packed), + request_id=request_id, total_size=len(packed), size=len(packed)) + + +def response_packet(packed: bytes): + """A response packet carrying packed (unencrypted: ResponseLink.decrypt is the identity).""" + return types.SimpleNamespace(context=RNS.Packet.RESPONSE, packet_type=RNS.Packet.DATA, data=packed) + + +class _Guarded(unittest.TestCase): + """Guards installed, with umsgpack.unpackb counted.""" + + def setUp(self): + guards.install() + self.unpackb = mock.Mock(wraps=umsgpack.unpackb) + patcher = mock.patch.object(umsgpack, "unpackb", self.unpackb) + patcher.start() + self.addCleanup(patcher.stop) + for name in ("_log_refused_response", "_log_unexpected_response"): + patcher = mock.patch.object(guards, name, mock.Mock()) + patcher.start() + self.addCleanup(patcher.stop) + + +class ResponseResourceTest(_Guarded): + """Responses sent as a Resource: RNS's own response_resource_concluded behind the guard.""" + + def test_guard_is_installed(self): + self.assertTrue(guards.installed()) + self.assertIs(RNS.Link.__dict__["response_resource_concluded"], guards.guarded_response_resource_concluded) + + def test_honest_responses_pass_unchanged(self): + for name, response in honest_responses().items(): + with self.subTest(name): + link = ResponseLink() + RNS.Link.response_resource_concluded(link, completed(umsgpack.packb([REQUEST_ID, response]))) + self.assertEqual(link.handled, [(REQUEST_ID, response)]) # decoded by RNS, as before + + def test_bombs_are_refused_without_decoding(self): + for size in (129 * 1024, MIB - 1): + for code in (0x90, 0x80): + with self.subTest(size=size, code=hex(code)): + link = ResponseLink() + self.unpackb.reset_mock() + RNS.Link.response_resource_concluded(link, completed(bomb(size, code))) + self.unpackb.assert_not_called() + self.assertEqual(link.handled, [(REQUEST_ID, None)]) # the request fails as malformed + + def test_shapes_no_parser_accepts_are_refused(self): + for response in ([[]] * 5, [{}] * 5, [1], "text", [bytes(MAX_BLOCK_SIZE + 1)], [None] * 27, + [[ID] * (K + 1), []], {b"a": b"b"}): + with self.subTest(response=repr(response)[:40]): + link = ResponseLink() + self.unpackb.reset_mock() + RNS.Link.response_resource_concluded(link, completed(umsgpack.packb([REQUEST_ID, response]))) + self.unpackb.assert_not_called() + self.assertEqual(link.handled, [(REQUEST_ID, None)]) + + def test_metadata_refused_on_rnfs_links(self): + link = ResponseLink() + RNS.Link.response_resource_concluded(link, completed(umsgpack.packb([REQUEST_ID, True]), has_metadata=True)) + self.assertEqual(link.handled, [(REQUEST_ID, None)]) + + def test_other_destinations_untouched(self): + # LXMF's links (and any not rnfs.dht) go straight to RNS, even with what rnfs would refuse + link = ResponseLink(LXMF) + RNS.Link.response_resource_concluded(link, completed(umsgpack.packb([REQUEST_ID, [[]] * 3]))) + self.assertEqual(link.handled, [(REQUEST_ID, [[], [], []])]) + self.unpackb.assert_called() + + def test_failed_resources_left_to_rns(self): + link = ResponseLink() + link.pending_requests = [mock.Mock(request_id=REQUEST_ID)] + resource = completed(bomb(4096, 0x90)) + resource.status = RNS.Resource.FAILED + RNS.Link.response_resource_concluded(link, resource) + link.pending_requests[0].request_timed_out.assert_called_once_with(None) # RNS's own handling + + def test_check_reads_headers_only(self): + # The check costs the same for any size: it never reads a bin's contents or builds objects + for packed in (bomb(MIB - 1, 0x90), umsgpack.packb([REQUEST_ID, [bytes(MAX_BLOCK_SIZE)] * 8])): + with mock.patch.object(protocol, "_bin_at", wraps=protocol._bin_at) as bins, \ + mock.patch.object(protocol, "_array_at", wraps=protocol._array_at) as arrays: + protocol.response_refusal(packed) + self.assertLessEqual(bins.call_count + arrays.call_count, protocol.MAX_BLOCKS_CIDS + 4) + + +class ResponsePacketTest(_Guarded): + """Responses sent as one packet: RNS's own Link.receive behind the guard.""" + + def setUp(self): + super().setUp() + self.received = [] + patcher = mock.patch.object(guards, "_original_receive", lambda link, packet: self.received.append(packet)) + patcher.start() + self.addCleanup(patcher.stop) + + def test_guard_is_installed(self): + self.assertIs(RNS.Link.__dict__["receive"], guards.guarded_receive) + + def test_honest_responses_pass_unchanged(self): + for name, response in honest_responses().items(): + with self.subTest(name): + link = ResponseLink() + packet = response_packet(umsgpack.packb([REQUEST_ID, response])) + RNS.Link.receive(link, packet) + self.assertEqual(self.received, [packet]) + self.received.clear() + + def test_bombs_refused_on_links_with_a_large_mtu(self): + # Only a link that negotiated a large MTU carries such a packet (TCP: 16 KiB and more) + for code in (0x90, 0x80): + link = ResponseLink() + RNS.Link.receive(link, response_packet(bomb(16 * 1024, code))) + self.assertEqual(self.received, []) + self.unpackb.assert_not_called() + self.assertEqual(link.handled, [(REQUEST_ID, None)]) + + def test_default_mtu_packets_not_decrypted_twice(self): + link = ResponseLink() + link.decrypt = mock.Mock() + packet = response_packet(bomb(guards.PACKET_CHECK_SIZE, 0x90)) + RNS.Link.receive(link, packet) + link.decrypt.assert_not_called() + self.assertEqual(self.received, [packet]) # at most ~500 objects: left to RNS + + def test_responses_on_incoming_links_dropped_unread(self): + link = ResponseLink(initiator=False) + link.decrypt = mock.Mock() + for packed in (umsgpack.packb([REQUEST_ID, True]), bomb(16 * 1024, 0x90)): + RNS.Link.receive(link, response_packet(packed)) + self.assertEqual(self.received, []) + link.decrypt.assert_not_called() + + def test_other_packets_and_destinations_untouched(self): + link = ResponseLink() + request = types.SimpleNamespace(context=RNS.Packet.REQUEST, packet_type=RNS.Packet.DATA, data=b"x" * 600) + RNS.Link.receive(link, request) + lxmf_link = ResponseLink(LXMF, initiator=False) + response = response_packet(bomb(16 * 1024, 0x90)) + RNS.Link.receive(lxmf_link, response) + self.assertEqual(self.received, [request, response]) + + +class MetadataAdvertisementTest(unittest.TestCase): + """RNS decodes a Resource's metadata as it completes, so rnfs.dht refuses metadata at the advertisement.""" + + def test_refused_on_rnfs_links_only(self): + adv = types.SimpleNamespace(t=1100, d=1000, u=False, l=1, i=1, x=True) + self.assertEqual(guards.refusal(adv, ResponseLink()), "rnfs responses carry no metadata") + self.assertEqual(guards.refusal(adv, ResponseLink(initiator=False)), "rnfs responses carry no metadata") + self.assertIsNone(guards.refusal(adv, ResponseLink(LXMF))) + adv.x = False + self.assertIsNone(guards.refusal(adv, ResponseLink())) + + def test_real_advertisement_flag(self): + # RNS packs has_metadata as the x flag; a response with metadata is a file response (an open file) + adv = RNS.ResourceAdvertisement.unpack(umsgpack.packb( + {"t": 1100, "d": 1000, "n": 1, "h": bytes(32), "r": bytes(4), "o": bytes(32), "i": 1, "l": 1, + "q": REQUEST_ID, "f": 0x01 | 0x10 | 0x20, "m": bytes(4)})) + self.assertTrue(adv.x) + self.assertEqual(guards.refusal(adv, ResponseLink()), "rnfs responses carry no metadata") + + +class ProviderFailureTest(_Guarded): + """A refused response fails the /blocks request (and so the provider) exactly as a malformed one does.""" + + def setUp(self): + super().setUp() + patcher = mock.patch.object(links, "RNS", fakes.fake_rns()) + patcher.start() + self.addCleanup(patcher.stop) + fakes.FakeLink.created = [] + fakes.FakeLink.establish_delay = 0.0 + self.pool = links.LinkPool(object(), Config(stall_timeout=5)) + self.addCleanup(self.pool.close_all) + self.client = client.PeerClient(self.pool, Config()) + self.cid = Cid.for_block(RAW, b"block") + + def fetch(self, packed: bytes) -> tuple[client.BlocksResult, object]: + """client.blocks() against a node whose response arrives as packed, through RNS behind the guard: the + result and how the request was counted in traffic.""" + def respond(link, path, data): + """What RNS hands the requester for packed: through the guard and RNS's own decoding.""" + rns_link = ResponseLink() + RNS.Link.response_resource_concluded(rns_link, completed(packed)) + ((_, response),) = rns_link.handled + return response + fakes.FakeLink.responder = staticmethod(respond) + self.client.traffic = mock.Mock() + result = fakes.run(self, lambda: self.client.blocks(bytes(16), [self.cid])) + return result, self.client.traffic.request.call_args + + def test_refused_response_fails_like_a_malformed_one(self): + # Malformed today: decodable, but more entries than CIDs requested, so the parser refuses it + malformed, malformed_traffic = self.fetch(umsgpack.packb([REQUEST_ID, [b"x", b"y", b"z"]])) + self.assertEqual((malformed.status, malformed.reason), (client.FAILED, "malformed response")) + for code in (0x90, 0x80): + with self.subTest(code=hex(code)): + refused, refused_traffic = self.fetch(bomb(129 * 1024, code)) + self.assertEqual((refused.status, refused.reason, refused.unsent, refused.blocks), + (malformed.status, malformed.reason, malformed.unsent, malformed.blocks)) + self.assertEqual(refused_traffic, malformed_traffic) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_rns_compat_security.py b/tests/security/test_rns_compat_security.py index 9b6ccc0..919fbba 100644 --- a/tests/security/test_rns_compat_security.py +++ b/tests/security/test_rns_compat_security.py @@ -10,10 +10,16 @@ link's status, attached_interface and request_time, and closes links with Link.teardown(); those are checked the same way, with one functional check against a real (offline) RNS.Destination. +The response pre-check (L1) replaces Link.response_resource_concluded and Link.receive, reads a completed +Resource's data, status, has_metadata, request_id and total_size, decrypts response packets with Link.decrypt +and fails a refused response through Link.handle_response; each is checked, with functional checks on a real +(offline) Link. The request rate limit (L17b) relies on RNS sending nothing for a handler that returns None. + Written against RNS 1.5.6. Nothing here starts Reticulum. """ import ast import inspect +import io import re import textwrap import unittest @@ -145,6 +151,112 @@ def test_guard_runs_for_a_link_request_to_a_real_destination(self): self.assertEqual(guards.half_open_links(destination), []) +class RnsResponsePrecheckInternalsTest(unittest.TestCase): + """Each RNS method guards hooks or calls to check responses before RNS decodes them (L1) is present and + behaves as rnfs expects.""" + + def setUp(self): + from rnfs.network import guards + self.guards = guards + guards.install() + + def test_response_resource_concluded_is_looked_up_on_the_link_and_decodes_the_data(self): + # RNS passes self.response_resource_concluded (looked up when the advertisement arrives, so the class + # attribute the guard replaces) as the response Resource's callback, which decodes resource.data + receive = _normal_source(RNS.Link._Link__receive) + self.assertIn("RNS.Resource.accept(packet,callback=self.response_resource_concluded,", receive) + original = _normal_source(self.guards._original_response_resource_concluded) + for expected in ("ifresource.status==RNS.Resource.COMPLETE:", "ifresource.has_metadata:", + "packed_response=resource.data.read()", "umsgpack.unpackb(packed_response)", + "self.handle_response(request_id,response_data,resource.total_size,resource.size)"): + self.assertIn(expected, original) + self.assertEqual(list(inspect.signature(self.guards._original_response_resource_concluded).parameters), + ["self", "resource"]) + + def test_completed_resource_data_is_a_seekable_file_and_request_id_is_set(self): + # assemble() marks the Resource COMPLETE, then hands the callback an open file the guard reads and rewinds + assemble = _normal_source(RNS.Resource.assemble) + self.assertIn("self.status=Resource.COMPLETE", assemble) + self.assertIn("self.data=open(self.storagepath,'rb')", assemble) + self.assertLess(assemble.index("self.data=open(self.storagepath,'rb')"), assemble.index("self.callback(self)")) + # Metadata is decoded inside assemble, before any callback: so rnfs refuses it at the advertisement + self.assertIn("self.metadata=umsgpack.unpackb(metadata_file.read())", assemble) + accept = _normal_source(self.guards._original_accept) + self.assertIn("resource=Resource(None,advertisement_packet.link,request_id=request_id)", accept) + self.assertIn("self.request_id=request_id", _normal_source(RNS.Resource.__init__)) + self.assertIn("resource.has_metadata=True", accept) + self.assertIn("ifadv.x:", accept) + self.assertIn("total_size", inspect.getsource(RNS.Resource.__init__) + inspect.getsource(RNS.Resource.accept)) + + def test_advertisement_x_flag_is_has_metadata(self): + from RNS.vendor import umsgpack + adv = RNS.ResourceAdvertisement.unpack(umsgpack.packb( + {"t": 100, "d": 90, "n": 1, "h": bytes(32), "r": bytes(4), "o": bytes(32), "i": 1, "l": 1, + "q": bytes(16), "f": 0x01 | 0x10 | 0x20, "m": bytes(4)})) + self.assertIs(adv.x, True) + self.assertIn("self.x=resource.has_metadata", _normal_source(RNS.ResourceAdvertisement.__init__)) + + def test_handle_response_hands_the_response_to_the_pending_request(self): + self.assertEqual(list(inspect.signature(RNS.Link.handle_response).parameters)[:5], + ["self", "request_id", "response_data", "response_size", "response_transfer_size"]) + source = _normal_source(RNS.Link.handle_response) + self.assertIn("ifpending_request.request_id==request_id:", source) + self.assertIn("pending_request.response_received(response_data,metadata)", source) + + def test_receive_is_called_on_the_link_and_decodes_response_packets(self): + # Transport hands every link packet to link.receive (the class attribute the guard replaces), which + # decrypts a RESPONSE packet and decodes it before matching it to a request + self.assertIn("else:link.receive(packet)", _normal_source(RNS.Transport)) + self.assertEqual(list(inspect.signature(self.guards._original_receive).parameters), ["self", "packet"]) + self.assertIn("self.__receive(packet)", _normal_source(self.guards._original_receive)) + receive = _normal_source(RNS.Link._Link__receive) + branch = receive[receive.index("elifpacket.context==RNS.Packet.RESPONSE:"):] + self.assertIn("packed_response=self.decrypt(packet.data)", branch) + self.assertIn("unpacked_response=umsgpack.unpackb(packed_response)", branch) + self.assertEqual(list(inspect.signature(RNS.Link.decrypt).parameters), ["self", "ciphertext"]) + self.assertIn("self.initiator", inspect.getsource(RNS.Link.__init__)) + + def test_default_mtu_packets_are_below_the_check_size(self): + # Response packets larger than RNS's default MTU are checked; a default-MTU link never carries one + self.assertEqual(self.guards.PACKET_CHECK_SIZE, RNS.Reticulum.MTU) + self.assertLess(RNS.Link.MDU + RNS.Identity.TOKEN_OVERHEAD + 16, self.guards.PACKET_CHECK_SIZE) + + def test_guards_run_on_a_real_link(self): + from unittest import mock + + from RNS.vendor import umsgpack + with mock.patch.object(RNS.Transport, "register_destination"): + dht = RNS.Destination(RNS.Identity(), RNS.Destination.IN, RNS.Destination.SINGLE, "rnfs", "dht") + link = RNS.Link(owner=dht) # incoming, as RNS makes one for a link request + self.assertTrue(self.guards.is_dht_link(link) and not link.initiator) + bomb = b"\x92\xc4\x10" + bytes(16) + b"\xdd\x00\x01\x00\x00" + b"\x90" * 65536 + with mock.patch.object(umsgpack, "unpackb", wraps=umsgpack.unpackb) as unpackb, \ + mock.patch.object(self.guards, "_log_refused_response"), \ + mock.patch.object(self.guards, "_log_unexpected_response"), \ + mock.patch.object(link, "decrypt") as decrypt: + link.receive(mock.Mock(context=RNS.Packet.RESPONSE, packet_type=RNS.Packet.DATA, data=bomb)) + decrypt.assert_not_called() # an incoming link's response packet is dropped unread + resource = mock.Mock(status=RNS.Resource.COMPLETE, has_metadata=False, data=io.BytesIO(bomb), + request_id=bytes(16), total_size=len(bomb), size=len(bomb)) + link.response_resource_concluded(resource) + unpackb.assert_not_called() + resource.data = io.BytesIO(umsgpack.packb([bytes(16), [b"block"]])) + link.response_resource_concluded(resource) + unpackb.assert_called_once() # an honest response reaches RNS's own decoding + + +class RnsRequestRateInternalsTest(unittest.TestCase): + """The request rate limit (L17b) answers a request over the limit with None: RNS then sends nothing.""" + + def test_none_response_sends_nothing(self): + source = _normal_source(RNS.Link.handle_request) + self.assertIn("ifresponse!=None:", source) + # and RNS starts a thread per request packet before any handler runs (what the limit can't prevent) + receive = _normal_source(RNS.Link._Link__receive) + branch = receive[receive.index("elifpacket.context==RNS.Packet.REQUEST:"):] + self.assertIn("threading.Thread(target=job,daemon=True).start()", branch) + + def _normal_source(function) -> str: """function's source without whitespace and with single quotes.""" return _normal(textwrap.dedent(_source_or_empty(function))) From 6c79d4cb97121c2c43dd5f8bbd50ae996ba4f8ca Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 10:55:17 +1030 Subject: [PATCH 066/107] Limit each incoming link's request rate before parsing, refusing a few requests over it and dropping the rest --- rnfs/network/server.py | 74 +++++++++++++-- tests/security/test_l17_request_rate.py | 115 ++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 tests/security/test_l17_request_rate.py diff --git a/rnfs/network/server.py b/rnfs/network/server.py index 8e9af61..a7117d3 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -1,8 +1,9 @@ """Answering other nodes' requests on rnfs.dht, within every section 3 limit, and the /blocks budget (section 7). RNS calls each request handler on a thread of its own and sends back whatever it returns. Malformed and -refused requests get False straight away, never silence (except requests over MAX_REQUEST_SIZE, which RNS -drops before any handler runs). +refused requests get False straight away, never silence, except requests over MAX_REQUEST_SIZE, which RNS +drops before any handler runs, and a flood over a link's request rate limit (REQUEST_BURST, REQUEST_RATE), +which after a few refusals gets no answer. The policy parts (response pools, deadlines and displacement, the /blocks budget, incoming-link eviction, upload pacing) are plain classes with an injected clock, so they can be tested without RNS. RequestServer @@ -56,6 +57,21 @@ # unmeasured_bitrate floor applies there FLOOR_FRACTION = 1 / 1000 COOLDOWN_MEMORY = 4096 # links and identities on a /blocks cooldown remembered at most +# Requests per incoming link (section 3): a token bucket of REQUEST_BURST, refilled at REQUEST_RATE a second. +# An honest requester waits for its answers, with at most MAX_REQUESTS_PER_LINK in progress, so it sends at most +# that many per round trip. Over real RNS on loopback (the shortest round trip there is), a 10,000-key /provide +# (182 requests, one at a time) ran at ~900 a second and two threads sending /find back to back at 2,100-3,600; +# the busiest link in the end-to-end tests saw 13 in a second. So the limit binds only on a link sending faster +# than any rnfs requester over a real network (or a tight loop like that last one, slowed to the rate). Requests over it are answered False while the link has refusals +# left (OVER_RATE_REFUSALS, one more a second), so a requester that gets there backs off as from any refusal, +# and dropped unanswered after that. RNS has already decrypted and decoded each request and started its thread +# by then; what the limit saves is the handler's work and the answer (about 40% of a /find's cost to the node) +REQUEST_BURST = 2000 +REQUEST_RATE = 2000.0 +OVER_RATE_REFUSALS = 2 * MAX_REQUESTS_PER_LINK +OVER_RATE_LOG_INTERVAL = 60.0 # seconds between log lines about requests over the rate limit +# IncomingLinks.take_token outcomes +REQUEST_OK, REQUEST_REFUSED, REQUEST_DROPPED = "ok", "refused", "dropped" class Backend(Protocol): @@ -305,16 +321,19 @@ def count(self, pool: str) -> int: class _LinkState: - """Requests in progress on one incoming link.""" + """Requests in progress on one incoming link, and its request rate limit.""" - __slots__ = ("requests", "blocks", "last_active", "waiting") + __slots__ = ("requests", "blocks", "last_active", "waiting", "tokens", "refusals", "refilled_at") def __init__(self, now: float): - """A link with nothing in progress, active now.""" + """A link with nothing in progress, active now, with full request and refusal buckets.""" self.requests = 0 self.blocks = 0 self.last_active = now self.waiting = False # a /blocks request is waiting for the previous response's proof + self.tokens = float(REQUEST_BURST) + self.refusals = float(OVER_RATE_REFUSALS) + self.refilled_at = now class IncomingLinks: @@ -378,6 +397,31 @@ def start_request(self, link_id: bytes, blocks: bool) -> bool: state.last_active = self.clock() return True + def take_token(self, link_id: bytes) -> str: + """Count a request arriving on link_id against the link's rate limit: REQUEST_OK, or, over the limit + (REQUEST_BURST at once, REQUEST_RATE a second after that), REQUEST_REFUSED while the link has refusals + left and REQUEST_DROPPED after that. A closed link isn't tracked again: its requests are let through, + to be refused by the handlers.""" + with self.lock: + if link_id in self.closed: + return REQUEST_OK + now = self.clock() + state = self.links.get(link_id) + if state is None: + # A link established before we were tracking; count it from now + state = self.links[link_id] = _LinkState(now) + elapsed = max(0.0, now - state.refilled_at) + state.refilled_at = now + state.tokens = min(float(REQUEST_BURST), state.tokens + elapsed * REQUEST_RATE) + state.refusals = min(float(OVER_RATE_REFUSALS), state.refusals + elapsed) + if state.tokens >= 1: + state.tokens -= 1 + return REQUEST_OK + if state.refusals >= 1: + state.refusals -= 1 + return REQUEST_REFUSED + return REQUEST_DROPPED + def start_waiting(self, link_id: bytes) -> bool: """Let one /blocks request on link_id wait for the previous response to conclude. False if one already is.""" with self.lock: @@ -525,6 +569,9 @@ def __init__(self, destination: RNS.Destination, config: Config, backend: Backen self.stop_event = threading.Event() self.wake = threading.Event() self.pin_identities = {h.strip().strip("<>").lower() for h in config.remote_pin_identities} + self.over_rate = 0 # requests over their link's rate limit, in all (refused or dropped) + self.over_rate_logged_at: float | None = None + self.over_rate_logged = 0 # Process-wide: refuses compressed incoming Resources (RNS decompresses at most this many bytes, so # any compressed payload fails) and stops RNS compressing outgoing ones (section 3) @@ -635,6 +682,11 @@ def _guarded(self, handler): """Wrap handler so an unexpected error answers False rather than leaving the requester waiting.""" def response_generator(path, data, request_id, link_id, remote_identity, requested_at): """RNS's six-argument request handler signature.""" + # First, before any parsing: the link's rate limit. None: RNS sends no answer + allowed = self.incoming.take_token(link_id) + if allowed != REQUEST_OK: + self._over_rate() + return False if allowed == REQUEST_REFUSED else None try: if self.config.private and not self._is_member(remote_identity): log(f"refused {path} from a non-member", LOG_VERBOSE) @@ -645,6 +697,18 @@ def response_generator(path, data, request_id, link_id, remote_identity, request return False return response_generator + def _over_rate(self): + """Count a request over its link's rate limit, logging at most once a minute.""" + with self.lock: + self.over_rate += 1 + now = self.clock() + if self.over_rate_logged_at is not None and now - self.over_rate_logged_at < OVER_RATE_LOG_INTERVAL: + return + dropped, self.over_rate_logged, self.over_rate_logged_at = (self.over_rate - self.over_rate_logged, + self.over_rate, now) + log(f"refused or dropped {plural(dropped, 'request')} over the per-link rate limit ({REQUEST_BURST} at " + f"once, {REQUEST_RATE:g} a second)", LOG_WARNING) + def _is_member(self, remote_identity) -> bool: """True if the requester identified as a member (private mode).""" return remote_identity is not None and bool(self.backend.is_member(remote_identity.hash)) diff --git a/tests/security/test_l17_request_rate.py b/tests/security/test_l17_request_rate.py new file mode 100644 index 0000000..5aaae1b --- /dev/null +++ b/tests/security/test_l17_request_rate.py @@ -0,0 +1,115 @@ +"""L17b: rnfs.dht had no request rate limit, and RNS starts a thread per request packet, so one link could have +the node parse, look up and answer requests as fast as it can send them. + +Fixed: RequestServer takes a token from a per-link bucket (REQUEST_BURST, refilled at REQUEST_RATE a second) +before it parses anything. Over the limit, a few requests are answered False (OVER_RATE_REFUSALS, one more a +second) and the rest dropped unanswered (RNS sends nothing for None). These tests drive the handlers on a +FakeClock: honest request patterns are never limited, a flood is, before any parsing, and only on its own link. +""" +import unittest +from unittest import mock + +from rnfs.constants import FIND_PATH, MAX_PROVIDE_KEYS, MAX_REQUESTS_PER_LINK, PROVIDE_PATH +from rnfs.network import protocol, server +from tests.security._server_helpers import ServerHarness + +KEY = bytes(range(16)) + + +class RequestRateTest(unittest.TestCase): + """The per-link request rate limit.""" + + def setUp(self): + self.harness = ServerHarness({}) + self.clock = self.harness.clock + self.link = self.harness.connect() + self.logged = [] + patcher = mock.patch.object(server, "log", lambda message, level=None: self.logged.append(message)) + patcher.start() + self.addCleanup(patcher.stop) + + def find(self, link=None): + """Send /find on link (default: the test's link) as RNS would; the handler's answer.""" + handler = self.harness.destination.handlers[FIND_PATH][0] + return handler(FIND_PATH, protocol.encode_find(KEY), bytes(16), (link or self.link).link_id, None, 0.0) + + def test_defaults_cover_honest_requesters(self): + # At most MAX_REQUESTS_PER_LINK requests per round trip: even at a 1 ms round trip, under the rate + self.assertGreaterEqual(server.REQUEST_RATE, MAX_REQUESTS_PER_LINK / 0.001) + # A whole 10,000-key /provide, sent back to back, fits the burst + self.assertGreaterEqual(server.REQUEST_BURST, -(-10_000 // MAX_PROVIDE_KEYS)) + + def test_honest_pace_never_limited(self): + # MAX_REQUESTS_PER_LINK requests every millisecond for 10 s: as fast as rnfs ever asked over loopback + answers = set() + for _ in range(10_000): + for _ in range(MAX_REQUESTS_PER_LINK): + answers.add(type(self.find())) + self.clock.now += 0.001 + self.assertEqual(answers, {list}) + self.assertEqual(self.harness.server.over_rate, 0) + + def test_flood_is_refused_then_dropped(self): + answers = [self.find() for _ in range(server.REQUEST_BURST)] + self.assertTrue(all(type(answer) is list for answer in answers)) + refused = [self.find() for _ in range(server.OVER_RATE_REFUSALS)] + self.assertEqual(refused, [False] * server.OVER_RATE_REFUSALS) # an honest requester backs off from these + self.assertEqual([self.find() for _ in range(1000)], [None] * 1000) # no answer: RNS sends nothing + self.assertEqual(self.harness.server.over_rate, server.OVER_RATE_REFUSALS + 1000) + + def test_bucket_refills_at_the_rate(self): + for _ in range(server.REQUEST_BURST + server.OVER_RATE_REFUSALS + 10): + self.find() + self.assertIsNone(self.find()) + self.clock.now += 1.5 / server.REQUEST_RATE # one and a half tokens' time + self.assertIsInstance(self.find(), list) + self.assertIsNone(self.find()) + self.clock.now += 1.0 # a second: REQUEST_RATE more requests, and one more refusal + answers = [self.find() for _ in range(int(server.REQUEST_RATE) + 2)] + self.assertEqual([type(answer) for answer in answers[:int(server.REQUEST_RATE)]], + [list] * int(server.REQUEST_RATE)) + self.assertEqual(answers[-2:], [False, None]) + + def test_refused_before_any_parsing(self): + for _ in range(server.REQUEST_BURST + server.OVER_RATE_REFUSALS): + self.find() + with mock.patch.object(protocol, "parse_find_request") as parse, \ + mock.patch.object(protocol, "parse_provide_request") as parse_provide, \ + mock.patch.object(self.harness.backend, "find") as find: + self.assertIsNone(self.find()) + handler = self.harness.destination.handlers[PROVIDE_PATH][0] + self.assertIsNone(handler(PROVIDE_PATH, [[KEY], []], bytes(16), self.link.link_id, None, 0.0)) + parse.assert_not_called() + parse_provide.assert_not_called() + find.assert_not_called() + + def test_other_links_unaffected(self): + for _ in range(server.REQUEST_BURST + server.OVER_RATE_REFUSALS + 5): + self.find() + self.assertIsNone(self.find()) + other = self.harness.connect() + self.assertIsInstance(self.find(other), list) + + def test_closed_link_still_answered_false(self): + self.link.teardown() + self.assertIs(self.find(), False) + self.assertNotIn(self.link.link_id, self.harness.server.incoming.links) # not tracked again + + def test_logged_at_most_once_a_minute(self): + for _ in range(server.REQUEST_BURST + 500): + self.find() + self.clock.now += 30 + for _ in range(server.REQUEST_BURST + 500): # refilled meanwhile: 500 over the limit again + self.find() + lines = [line for line in self.logged if "rate limit" in line] + self.assertEqual(len(lines), 1) + self.clock.now += server.OVER_RATE_LOG_INTERVAL + for _ in range(server.REQUEST_BURST + 1): + self.find() + lines = [line for line in self.logged if "rate limit" in line] + self.assertEqual(len(lines), 2) + self.assertIn("refused or dropped 1000 requests", lines[1]) # all those since the first line + + +if __name__ == "__main__": + unittest.main() From 42aadcacba6cb2561bad51ee7eabf50e9349388e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 10:55:17 +1030 Subject: [PATCH 067/107] Cool down a requester that rejects a /blocks response before any of it is sent --- rnfs/network/server.py | 22 ++++- tests/security/test_l17_rejected_responses.py | 92 +++++++++++++++++++ tests/security/test_rns_compat_security.py | 24 ++++- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 tests/security/test_l17_rejected_responses.py diff --git a/rnfs/network/server.py b/rnfs/network/server.py index a7117d3..dfd89ec 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -1074,14 +1074,32 @@ def _budget_share_left(self, response: Response) -> int | None: return int(bucket.capacity // 2) - in_flight def _concluded(self, response: Response, link, resource): - """The response's Resource concluded: record the rate if it completed, and free the slots.""" + """The response's Resource concluded: record the rate if it completed, start a cooldown if its requester + rejected it with nothing sent, and free the slots.""" with self.lock: # The Resource's callback, the monitor and the next request may all notice; handle it once first, response.concluded = not response.concluded, True - if first and getattr(resource, "status", None) == RNS.Resource.COMPLETE: + status = getattr(resource, "status", None) + if first and status == RNS.Resource.COMPLETE: self.rates.record(response.link_id, response.size, self._expected_rate(link)) + elif first and status == RNS.Resource.REJECTED and not self._sent_any(response, resource): + # Turned down at its advertisement (no honest requester does that): no progress, so its requester + # cools down as for a response cancelled for making none, rather than having the node build another + # at once (section 3) + log("a /blocks response was rejected with nothing sent: its requester cools down", LOG_VERBOSE) + self.cooldowns.add(response.link_id, response.identity) self._finish(response) # charges what was sent: all of a completed Resource + @staticmethod + def _sent_any(response: Response, resource) -> bool: + """Whether any of response's Resource was sent (or that can't be read).""" + if response.sent > 0: + return True + try: + return resource.get_progress() > 0 + except Exception: + return True + def _cancel(self, response: Response, reason: str): """Stop sending a response: cancel its Resource, or close its link if we can't reach the Resource.""" with self.lock: diff --git a/tests/security/test_l17_rejected_responses.py b/tests/security/test_l17_rejected_responses.py new file mode 100644 index 0000000..3a52398 --- /dev/null +++ b/tests/security/test_l17_rejected_responses.py @@ -0,0 +1,92 @@ +"""L17a: a requester could have the node build /blocks responses in a loop by rejecting each one's advertisement +and asking again at once: a rejected Resource concludes immediately, freeing the slot, and no cooldown applied. + +Fixed: a response whose Resource is rejected with nothing sent counts as making no progress, so its link (and +identity) cools down for blocks_cooldown, as after one cancelled for making none (H1). These tests drive the +server on a FakeClock with fake Resources concluding as RNS's do on a rejection (status REJECTED, then the +callback): the loop builds one response per cooldown, and honest outcomes (completed, or rejected mid-transfer) +start no cooldown. +""" +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE +from rnfs.content.cid import RAW, Cid +from tests.security._server_helpers import ServerHarness + +BLOCK = bytes(CHUNK_SIZE) +CID = Cid.for_block(RAW, BLOCK) + + +def reject(resource, progress: float = 0.0): + """The requester rejects (or, mid-transfer, cancels) the response: RNS's Resource._rejected on an outgoing + Resource sets REJECTED, leaves the link's outgoing Resources and runs the callback.""" + resource.progress = progress + resource.status = RNS.Resource.REJECTED + resource.conclude() + if resource.callback: + resource.callback(resource) + + +class RejectedResponseTest(unittest.TestCase): + """Rejected /blocks responses and the cooldown.""" + + def setUp(self): + self.harness = ServerHarness({CID: BLOCK}, Config(blocks_cooldown=60)) + self.clock = self.harness.clock + + def respond(self, link, identity=None): + """Request a block on link and advertise the response; its Resource, or None if refused.""" + answer = self.harness.blocks(link, [CID], max_bytes=CHUNK_SIZE, identity=identity) + if answer is False: + return None + self.assertEqual(answer, [BLOCK]) + return self.harness.advertise(link) + + def test_request_and_reject_loop_builds_one_response_per_cooldown(self): + link = self.harness.connect() + built = 0 + end = self.clock.now + 10 + while self.clock.now < end: # ask again 10 ms after each rejection + resource = self.respond(link) + if resource is not None: + built += 1 + reject(resource) + self.clock.now += 0.01 + self.harness.server.check_responses() + self.assertEqual(built, 1) # 1,001 before the fix + self.assertTrue(self.harness.server.cooldowns.active(link.link_id)) + self.clock.now += 60 + self.assertIsNotNone(self.respond(link)) # and again once the cooldown is over + + def test_identity_cools_down_on_new_links(self): + identity = RNS.Identity() + reject(self.respond(self.harness.connect(), identity)) + self.assertIsNone(self.respond(self.harness.connect(), identity)) + self.assertIsNotNone(self.respond(self.harness.connect())) # another requester is unaffected + + def test_rejection_noticed_by_the_monitor_also_counts(self): + # Without the callback (the monitor finds the Resource concluded), the same applies + link = self.harness.connect() + resource = self.respond(link) + resource.callback = None + resource.status = RNS.Resource.REJECTED + resource.conclude() + self.harness.server.check_responses() + self.assertIsNone(self.respond(link)) + + def test_honest_outcomes_start_no_cooldown(self): + link = self.harness.connect() + self.respond(link).complete() + resource = self.respond(link) + self.assertIsNotNone(resource) + # Rejected after some of it was sent (RNS's receiver gives up mid-transfer the same way): it made progress + reject(resource, progress=0.5) + self.assertIsNotNone(self.respond(link)) + self.assertFalse(self.harness.server.cooldowns.active(link.link_id)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_rns_compat_security.py b/tests/security/test_rns_compat_security.py index 919fbba..5e0d94a 100644 --- a/tests/security/test_rns_compat_security.py +++ b/tests/security/test_rns_compat_security.py @@ -13,7 +13,9 @@ The response pre-check (L1) replaces Link.response_resource_concluded and Link.receive, reads a completed Resource's data, status, has_metadata, request_id and total_size, decrypts response packets with Link.decrypt and fails a refused response through Link.handle_response; each is checked, with functional checks on a real -(offline) Link. The request rate limit (L17b) relies on RNS sending nothing for a handler that returns None. +(offline) Link. The request rate limit (L17b) relies on RNS sending nothing for a handler that returns None, +and the cooldown after a rejected response (L17a) on how RNS concludes an outgoing Resource the receiver +rejects. Written against RNS 1.5.6. Nothing here starts Reticulum. """ @@ -257,6 +259,26 @@ def test_none_response_sends_nothing(self): self.assertIn("threading.Thread(target=job,daemon=True).start()", branch) +class RnsRejectedResourceInternalsTest(unittest.TestCase): + """A rejected outgoing Resource concludes as the server expects (L17a): status REJECTED, then the callback, + with get_progress() counting the parts sent.""" + + def test_rejection_sets_rejected_and_runs_the_callback(self): + receive = _normal_source(RNS.Link._Link__receive) + branch = receive[receive.index("elifpacket.context==RNS.Packet.RESOURCE_RCL:"):] + self.assertIn("forresourceinself.outgoing_resources:ifresource_hash==resource.hash:resource._rejected()", + branch) + rejected = _normal_source(RNS.Resource._rejected) + for expected in ("self.status=Resource.REJECTED", "self.link.cancel_outgoing_resource(self)", + "self.callback(self)"): + self.assertIn(expected, rejected) + self.assertGreater(RNS.Resource.REJECTED, RNS.Resource.COMPLETE) # the monitor treats it as concluded + + def test_progress_of_an_outgoing_resource_counts_sent_parts(self): + progress = _normal_source(RNS.Resource.get_progress) + self.assertIn("elifself.initiator:ifnotself.split:self.processed_parts=self.sent_parts", progress) + + def _normal_source(function) -> str: """function's source without whitespace and with single quotes.""" return _normal(textwrap.dedent(_source_or_empty(function))) From 4f203e4dc17f75b52b6fe9c1a7d29f2d68644ae4 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 11:00:52 +1030 Subject: [PATCH 068/107] Document the response header check, the per-link request rate limit and the cooldown after a rejected response --- CHANGELOG.md | 20 ++++++++++++++++++++ design.md | 25 +++++++++++++++++++++---- specification.md | 42 +++++++++++++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ef5fd..5f39555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,9 +113,29 @@ Serving `/blocks` under load: `max_incoming_links`, on `rnfs.dht` and the LXMF delivery destination. When it is full the oldest one on the busiest interface is closed. RNS keeps such links (each with a thread) for at least 366 s and rnfs only saw links once established, so a flood of link requests could exhaust the node's memory or threads. +- A link (and its identity) that rejects a `/blocks` response's advertisement, so that none of it is sent, + cools down for `blocks_cooldown` as after a response that made no progress. A rejected Resource frees its + slot at once, so a requester could have the node build a response, reject it and ask again in a loop (a + response of up to 128 KiB on a link with no rate estimate, about 1 ms of CPU each); now it gets one per + cooldown. No honest requester rejects a response. +- Each incoming link may send 2000 requests at once and 2000 a second after that. Requests over the limit are + refused before anything is parsed, and after 4 refusals (one more a second) dropped unanswered. RNS has + already decoded a request and started a thread for it by then, so this only removes rnfs's share of a + flood's cost (about 40% of the node's CPU per request, measured); honest requesters, which wait for their + answers, stay far below it. - Known limit: many unidentified links that each hold a slot for one grace, then sit out their cooldown, can still deny the fast pool over a shared interface (specification section 10). +Fetching from other nodes: +- On `rnfs.dht` links a response's msgpack headers are checked before RNS decodes it, and one that can't be + an rnfs response is refused: the request fails as for a malformed response. RNS decoded every response + whole first, and a response of one-byte empty arrays or maps cost about 70 times its size in memory (a + 1 MiB response: ~70 MB and ~0.3 s of CPU). The check reads only headers, so it costs the same for any size. + Response packets are checked when larger than RNS's default MTU (links that negotiated a larger one), and a + response packet arriving on an incoming `rnfs.dht` link, which has no requests, is dropped unread. Resources + carrying metadata, which RNS also decodes first and rnfs never sends, are refused on `rnfs.dht` links. + Other destinations (LXMF) are unaffected. + DHT and names: - A full provider store keeps a tenth of its room as a newcomer pool, with random eviction inside it, so a publisher it hasn't seen can still store records; about 100 identities filling the store used to lock out diff --git a/design.md b/design.md index bd63b91..a961cf4 100644 --- a/design.md +++ b/design.md @@ -66,8 +66,9 @@ rnfs/ protocol.py message encoding/validation, batch sizes (sections 3, 6, 7, 8) rates.py rate estimates, slow links, slow node detection (section 2) links.py LinkPool: shared unidentified links, identified one-shot links, idle close (section 3) - guards.py process-wide guard rejecting oversized or request Resource advertisements (3, 10); the - half-open incoming link cap per destination (3) + guards.py process-wide guards rejecting oversized or request Resource advertisements and, on + rnfs.dht links, responses that fail a msgpack header check before RNS decodes them + (3, 10); the half-open incoming link cap per destination (3) server.py RequestServer: handlers, every section 3 limit, airtime policy, feature paths (3, 7) client.py PeerClient: /find, /provide, /blocks (with stall detection), /pin, ping (sections 3, 6, 7) announces.py rnfs.dht announces carrying the DHT mode, and the announce handler (section 3) @@ -338,7 +339,13 @@ and in total, never persisted. newcomer's own link. `check_responses` cancels and refunds a response that has sent nothing once its grace plus `deadline_slack` has passed, or that makes no progress for `stall_timeout`. `Cooldowns` then refuses `/blocks` from a link (and its identity, if identified) whose response was displaced or cancelled for making - no progress, for `blocks_cooldown`, cheaply and before any slot is taken. + no progress, for `blocks_cooldown`, cheaply and before any slot is taken. `_concluded` starts the same + cooldown for a response whose Resource the requester rejected (`REJECTED`) with nothing sent (`_sent_any`: + `Response.sent`, else the Resource's `get_progress()`). +- `IncomingLinks.take_token` keeps a per-link token bucket (`REQUEST_BURST`, `REQUEST_RATE`) and a smaller + one of refusals (`OVER_RATE_REFUSALS`, one a second) in `_LinkState`. `RequestServer._guarded` asks it first + for every path: over the limit it answers `False` while refusals are left, else `None`, which RNS sends + nothing for; `_over_rate` counts them and logs at most once a minute. - `RequestServer.airtime` checks interface rules, quiet hours and an empty budget before taking any slot, caps the block budget, and charges the bucket as responses are sent. Each `Response` records its `cost` (packed size), what has been `charged` so far and its requester (identity hash, else link ID). `_admit` @@ -351,7 +358,17 @@ and in total, never persisted. (`_budget_share_left` cuts the budget, or refuses). `RequestServer.traffic` and `PeerClient.traffic` share one `Traffic`. - `network/guards.py` also rejects, on `rnfs.dht` links only (recognised by the link's destination or owner - name `rnfs.dht.`), any Resource advertisement with more than one segment. + name `rnfs.dht.`), any Resource advertisement with more than one segment or with metadata. +- `guards.install()` also replaces `RNS.Link.response_resource_concluded` and `RNS.Link.receive` (class + attributes; RNS looks both up on the link). On `rnfs.dht` links, `guarded_response_resource_concluded` reads + a completed response Resource's data (an open file, rewound for RNS) and `guarded_receive` decrypts a + response packet larger than `PACKET_CHECK_SIZE` (RNS's default MTU), and both pass it to + `protocol.response_refusal`, which walks msgpack headers (`_array_at`, `_bin_at`) without building + objects: `[16-byte bin, response]`, the response `True`, `False`, `nil`, a `/find` answer or at most + `MAX_BLOCKS_CIDS` (26) entries of `bin` up to `MAX_BLOCK_SIZE` or `nil`, and nothing after it. A refused + response is handed to `Link.handle_response` as `None`, which every parser refuses, so the request fails as + malformed; refusals are logged at most once a minute. `guarded_receive` drops a response packet on an + incoming `rnfs.dht` link without decrypting it. Everything else goes to RNS's own methods unchanged. - `guards.limit_half_open(destination, limit)` replaces a destination's `incoming_link_request` (on the instance; RNS's `Destination.receive` calls it for each link request) with `HalfOpenLimit`, which, before RNS validates a request, closes (`Link.teardown()`) the oldest `PENDING`/`HANDSHAKE` link in diff --git a/specification.md b/specification.md index 3cb4752..72d1962 100644 --- a/specification.md +++ b/specification.md @@ -120,7 +120,8 @@ pass a `max_response_size` just above their largest honest answer. - Field types: keys, node IDs and names are 16-byte `bin`; CIDs are binary CIDs as `bin`; blocks are `bin`; `max_bytes` is a non-negative integer; `pin` is a boolean; every `/name` response field is `bin`. - Any request of another shape, or one refused under the limits below, is answered `False` at once — never - left unanswered, except a request over `MAX_REQUEST_SIZE`, which RNS drops unanswered. + left unanswered, except a request over `MAX_REQUEST_SIZE`, which RNS drops unanswered, and a flood over the + per-link request rate (below), which after a few refusals is dropped unanswered. - Every request fits in one packet: at most `min(Link.get_mdu(), MAX_REQUEST_SIZE)` bytes including RNS's request framing (~28 bytes). Batch sizes are computed from that (e.g. 22 keys per `/provide` at the default MTU, counting both lists). A `/provide` carries at most 55 keys, counting both lists (the most that fit @@ -130,6 +131,15 @@ pass a `max_response_size` just above their largest honest answer. - Requests are at most `MAX_REQUEST_SIZE` (`Destination.set_max_request_size`). - Per link: at most `MAX_REQUESTS_PER_LINK` requests in progress, of which at most one is `/blocks`. Requesters MUST stay within this. +- **Request rate per link.** RNS decrypts and decodes every request packet and starts a thread for it before + any handler runs, so nothing in rnfs stops that work; a node SHOULD at least keep a flood from costing it + the handler's work and an answer. rnfs takes a token from a per-link bucket (2000 requests, refilled at 2000 + a second) before parsing anything. A request over it is answered `False` while the link has refusals left + (4, one more a second), so a requester that gets there backs off as from any refusal, and dropped + unanswered after that. An honest requester waits for its answers, `MAX_REQUESTS_PER_LINK` at a time, so it + stays far below this over a real network (over loopback a 10,000-key `/provide` runs at about 900 requests + a second). Measured over real RNS, the limit cuts the node's CPU per flood request by about 40% (the rest is + RNS's own work), so the flood rate one link needs to saturate the node about doubles. - At most `max_blocks_responses` `/blocks` responses are in progress in total, split between two pools: responses on slow links (`max_blocks_responses` // 2 slots) and on other links (the rest), so each pool has at least one slot. A response stays in the pool it started in, even if its link's class changes. Each @@ -153,7 +163,9 @@ pass a `max_response_size` just above their largest honest answer. - A response that makes **no progress** is cancelled and its budget charge refunded: one with nothing sent, once its grace plus `deadline_slack` has passed; any response with no progress for `stall_timeout`. - **Cooldown.** A link (and its identity, if it identified) whose response was displaced or cancelled for - making no progress is refused new `/blocks` for `blocks_cooldown`. The refusal is answered at once, before + making no progress, or whose response Resource it rejected before any of it was sent (no honest requester + does: otherwise one could have the node build a response, reject it and ask again in a loop), is refused + new `/blocks` for `blocks_cooldown`. The refusal is answered at once, before any slot is reserved or block read. - A handler gets no direct handle on the response Resource RNS sends for it. Responses are tracked through the link's outgoing Resources (`Link.outgoing_resources`, matched by `request_id`) and cancelled with @@ -180,8 +192,19 @@ pass a `max_response_size` just above their largest honest answer. (rnfs requests always fit one packet), or whose transfer size exceeds its data size or `MAX_RESPONSE_SIZE` by more than the encryption overhead (rnfs: 128 bytes). RNS checks those limits per segment, so on `rnfs.dht` links (either end) a node SHOULD also reject any advertisement of more than one segment: every - rnfs response fits one (`MAX_RESPONSE_SIZE`). Other protocols' Resources in the same process (LXMF) are - not affected. + rnfs response fits one (`MAX_RESPONSE_SIZE`). On `rnfs.dht` links it SHOULD also reject one with metadata, + which RNS decodes as the Resource completes and rnfs responses never carry. Other protocols' Resources in + the same process (LXMF) are not affected. +- RNS decodes a whole response with msgpack before handing it over, and one-byte empty arrays or maps decode + to some 70 times their size in objects (a 1 MiB response: ~70 MB and ~0.3 s of CPU). So on `rnfs.dht` + links a node SHOULD check a response's msgpack headers before RNS decodes it, without building objects, and + refuse one that can't be an rnfs response: anything but `[request_id, response]` with a 16-byte `bin` + request ID and a response that is `True`, `False`, `nil`, a `/find` answer, or a list of at most 26 entries + (the most CIDs a `/blocks` request can carry), each a `bin` of at most `MAX_BLOCK_SIZE` or `nil`, with + nothing after it. rnfs checks responses sent as a Resource, and those sent as one packet larger than RNS's + default MTU (only links that negotiated a larger MTU carry them; smaller ones decode to little). A refused + response fails its request as a malformed one does. A responder sends no requests, so a response packet on + an incoming `rnfs.dht` link is dropped unread. - `max_upload_rate` caps bytes sent for `/blocks` on a link once it has a rate estimate. RNS paces a Resource by its receiver, so the cap is applied between responses: the response budget uses the lower of the rate estimate and `max_upload_rate`, and the next response on that link starts no sooner than @@ -841,9 +864,9 @@ as is cheap. Rules live in the sections referenced. | Hiding content near a key | Provider search doesn't stop at the first reply that lists providers (6) | | Routing table takeover with new identities | Only proven-reachable nodes stored; full buckets keep long-lived nodes (6) | | Fake node IDs in replies | Unknown IDs never stored; at most `alpha` path requests for them per lookup; lookup time budget (6) | -| Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement protection earned by progress (a per-response grace, then a throughput floor), so a response that transfers nothing can't hold a slot; no-progress and stall cancellation with refund; a cooldown on a link whose response made no progress; requester stall detection (2, 3, 7) | -| Request flooding, link exhaustion | Request size, per-link and global limits; eviction of idle incoming links; half-open links capped in their own bucket, oldest closed first (3) | -| Decompression and oversized-response bombs | Compressed Resources refused; response sizes capped; multi-segment Resources refused on `rnfs.dht` links and on links to LXMF propagation nodes (3, 7, 13) | +| Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement protection earned by progress (a per-response grace, then a throughput floor), so a response that transfers nothing can't hold a slot; no-progress and stall cancellation with refund; a cooldown on a link whose response made no progress or was rejected unsent; requester stall detection (2, 3, 7) | +| Request flooding, link exhaustion | Request size, per-link and global limits; a per-link request rate limit; eviction of idle incoming links; half-open links capped in their own bucket, oldest closed first (3) | +| Decompression and oversized-response bombs | Compressed Resources refused; response sizes capped; multi-segment Resources refused on `rnfs.dht` links and on links to LXMF propagation nodes; `rnfs.dht` responses' msgpack headers checked before RNS decodes them (3, 7, 13) | | Reading another node's download history | Only provided content is served (7). Under the default `roots`, complete cached downloads are provided; `pinned` keeps them private | | Name rollback or forgery | Self-certifying names; RNS-verified signatures; persisted highest sequence (9) | | Forged or withheld kept names | Records verified against the name's key; highest persisted sequence; several keepers asked (9) | @@ -893,6 +916,11 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. - A provider can lower its own measured rate, and so slow a fetch from it; on slow nodes, which use one provider at a time, this can delay a fetch for a long time. - Mass announces from new identities compete for RNS's announce bandwidth, which RNS itself caps. +- RNS decrypts and decodes every request packet and starts a thread for it before rnfs sees it, so a link + sending requests as fast as its interface carries them still costs the node that work (about 0.14 ms of CPU + each over loopback): the request rate limit (section 3) removes only rnfs's share. Likewise a node rejecting + `/blocks` responses only after RNS has received them can be made to build one per cooldown per link, and + RNS spends a key exchange on every new link. - Any web page can make the local gateway fetch content (a plain request to `127.0.0.1` passes the Host check), and can time responses to guess what is cached. The gateway's own limits (section 11) bound the airtime this costs: `gateway_fetches`, and no `?format=` response (raw block or archive) at all for a web From cc0736d9289f631a91f0083fc4293e18db6fda74 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 11:02:23 +1030 Subject: [PATCH 069/107] Fuzz the response header check with mutated honest responses --- tests/security/test_l1_response_precheck.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/security/test_l1_response_precheck.py b/tests/security/test_l1_response_precheck.py index 47e14ea..edcaff0 100644 --- a/tests/security/test_l1_response_precheck.py +++ b/tests/security/test_l1_response_precheck.py @@ -8,6 +8,7 @@ and a refused response fails the /blocks request exactly as a malformed response does. """ import io +import random import types import unittest from unittest import mock @@ -158,6 +159,25 @@ def test_failed_resources_left_to_rns(self): RNS.Link.response_resource_concluded(link, resource) link.pending_requests[0].request_timed_out.assert_called_once_with(None) # RNS's own handling + def test_mutated_responses_never_raise_and_only_decodable_ones_pass(self): + rng = random.Random(1) + seeds = [umsgpack.packb([REQUEST_ID, response]) for response in honest_responses().values() + if len(umsgpack.packb([REQUEST_ID, response])) < 4096] + for _ in range(5000): + packed = bytearray(rng.choice(seeds)) + for _ in range(rng.randint(1, 4)): + position = rng.randrange(len(packed) + 1) + choice = rng.random() + if choice < 0.4 and position < len(packed): + packed[position] = rng.randrange(256) + elif choice < 0.7: + del packed[position:] + else: + packed.insert(position, rng.randrange(256)) + packed = bytes(packed) + if protocol.response_refusal(packed) is None: + self.unpackb(packed) # anything let through decodes + def test_check_reads_headers_only(self): # The check costs the same for any size: it never reads a bin's contents or builds objects for packed in (bomb(MIB - 1, 0x90), umsgpack.packb([REQUEST_ID, [bytes(MAX_BLOCK_SIZE)] * 8])): From 5092f80481fb813addc00b5f4906b6d0b18d4e83 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 13:55:32 +1030 Subject: [PATCH 070/107] Render the file-index-node page's Files section again: the page body no longer rebinds shown() --- .../file-index-node/index.mu | 6 +- tests/security/test_m6_index_page_full_run.py | 94 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/security/test_m6_index_page_full_run.py diff --git a/companion-nomadnet-sites/file-index-node/index.mu b/companion-nomadnet-sites/file-index-node/index.mu index 796f27e..0d8dd61 100755 --- a/companion-nomadnet-sites/file-index-node/index.mu +++ b/companion-nomadnet-sites/file-index-node/index.mu @@ -208,7 +208,7 @@ except Exception as error: # the daemon isn't running, rnfs isn't installed, .. else: folders = [pin for pin in pins if pin.get("type") == "directory"] files = [pin for pin in pins if pin.get("type") != "directory"] - shown = section("Folders", folders) - shown = section("Files", files) or shown - if not shown: + listed = section("Folders", folders) + listed = section("Files", files) or listed + if not listed: print("Nothing is shared yet.") diff --git a/tests/security/test_m6_index_page_full_run.py b/tests/security/test_m6_index_page_full_run.py new file mode 100644 index 0000000..529b700 --- /dev/null +++ b/tests/security/test_m6_index_page_full_run.py @@ -0,0 +1,94 @@ +"""M6 follow-up: the fixed file-index-node page never rendered its Files section. + +The M6 fix added a function shown(pin), which entry() calls, but the page body later bound a module-level +variable of the same name to section()'s result. By the time the Files section ran, shown was a bool, so +entry() raised TypeError ("'bool' object is not callable") and the page printed no file at all. The other page +tests exec only the page's definitions (index_page_namespace), so none of them ran the body. + +These tests run the whole page script over an offline node's real named pins (the CLI served in-process by +fake_rnfs) and check what it prints. +""" +import os +import tempfile +import unittest + +import RNS + +from tests.security import _pages_helpers as helpers + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class IndexPageFullRunTest(unittest.TestCase): + """The whole file-index-node script, run against one offline node's pins.""" + + def setUp(self): + """One offline node and a page data dir.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _, self.node, _ = helpers.make_nodes(self.tmp.name) + self.addCleanup(self.node.stop) + self.data_dir = os.path.join(self.tmp.name, "page-data") + + def add_file(self, name, content=b"shared data"): + """Add a file named name (a user pin); its CID.""" + path = os.path.join(self.tmp.name, name) + with open(path, "wb") as f: + f.write(content) + return self.node.add_path(path)["cid"] + + def add_folder(self, name): + """Add a folder named name holding one file (a user pin); its CID.""" + folder = os.path.join(self.tmp.name, name) + os.makedirs(folder) + with open(os.path.join(folder, "inside.txt"), "wb") as f: + f.write(b"inside") + return self.node.add_path(folder, wrap=True)["cid"] + + def page(self) -> str: + """What the whole page script prints for this node's pins.""" + try: + return helpers.exec_index_page(helpers.fake_rnfs(self.node, []), self.data_dir) + except TypeError as error: + # fails until the page body stops rebinding shown(), which entry() calls + self.fail(f"the page crashed: {type(error).__name__}: {error}") + + def test_files_section_lists_named_files(self): + """With only file pins, the Files section lists each by name and CID.""" + guide = self.add_file("field-guide.pdf") + notes = self.add_file("notes.txt", b"notes") + page = self.page() + self.assertIn(">Files", page) + for name, cid in (("field-guide.pdf", guide), ("notes.txt", notes)): + self.assertIn(f"`!{name}`!", page) + self.assertIn(cid, page) + self.assertNotIn("Nothing is shared yet.", page) + + def test_folders_and_files_are_both_listed(self): + """With a folder pin as well, both sections render, Folders first.""" + self.add_folder("maps") + self.add_file("field-guide.pdf") + page = self.page() + self.assertLess(page.index(">Folders"), page.index("`!maps`!")) + self.assertLess(page.index("`!maps`!"), page.index(">Files")) + self.assertLess(page.index(">Files"), page.index("`!field-guide.pdf`!")) + + def test_nothing_shared(self): + """With no pins, the page says nothing is shared.""" + page = self.page() + self.assertIn("Nothing is shared yet.", page) + self.assertNotIn(">Files", page) + + +if __name__ == "__main__": + unittest.main() From 49df3c91e3ebcc500aa991991d0359731e886f66 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 13:56:23 +1030 Subject: [PATCH 071/107] Skip remote_pins.json entries that aren't a pair of strings, so one can't stop the node from starting --- rnfs/node.py | 7 ++ .../test_new6_remote_pins_bad_entry.py | 97 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/security/test_new6_remote_pins_bad_entry.py diff --git a/rnfs/node.py b/rnfs/node.py index 12326a8..828f216 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -1057,16 +1057,23 @@ def _load_remote_pins(self) -> list[tuple[str, Cid]]: return [] pins: dict[tuple[str, Cid], None] = {} counts: dict[str, int] = {} + malformed = 0 for entry in saved: try: source, text = entry + if not isinstance(source, str) or not isinstance(text, str): + raise TypeError("not a pair of strings") # a list or dict source can't even be looked up cid = Cid.parse(text) except (ValueError, TypeError): + malformed += 1 continue if source not in allowed or (source, cid) in pins or counts.get(source, 0) >= REMOTE_PIN_QUEUE: continue counts[source] = counts.get(source, 0) + 1 pins[(source, cid)] = None + if malformed: + log(f"skipped {malformed} malformed entr{'y' if malformed == 1 else 'ies'} in {self._remote_pins_path}", + LOG_WARNING) return list(pins) def _resume_remote_pins(self): diff --git a/tests/security/test_new6_remote_pins_bad_entry.py b/tests/security/test_new6_remote_pins_bad_entry.py new file mode 100644 index 0000000..21e6c59 --- /dev/null +++ b/tests/security/test_new6_remote_pins_bad_entry.py @@ -0,0 +1,97 @@ +"""NEW-6 follow-up: one malformed entry in state/remote_pins.json stopped the node from starting. + +_load_remote_pins unpacked each saved [source, cid] pair inside a try, but looked the source up in a set +outside it: a source that is a JSON list or object is unhashable, so the TypeError escaped _resume_remote_pins +and _start_threads, and the node failed to start. The fixed node skips such entries (with a log line) and +resumes the rest. + +The pinning node is an offline node; a provider joined to it by tests.test_node's NetClient holds the content. +The file is written as a damaged or hand-edited one might be, and the node is started the way RnfsNode.__init__ +starts it (_start_threads). +""" +import io +import json +import os +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs import node as node_module +from rnfs.config import Config +from rnfs.content.cid import Cid +from rnfs.content.storage import remote_source +from rnfs.node import REMOTE_PINS_FILE, RnfsNode +from tests.support import wait_for +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, B_ID, NetClient + +IDENTITY = bytes(range(16)) +SOURCE = remote_source(IDENTITY) + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class RemotePinsBadEntryTest(unittest.TestCase): + """Provider A holds a file; node B starts with a remote_pins.json holding malformed entries and a good one.""" + + def setUp(self): + """The provider, a file it provides, and the pinning node's config (accepting /pin from IDENTITY).""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.net = NetClient() + self.net.sender = B_ID + self.provider = self.open("a", A_ID) + self.cid = Cid.parse(self.provider.add_stream(io.BytesIO(os.urandom(3000)))["cid"]) + self.provider.records.store(self.cid.key, A_ID) + + def open(self, name, node_id) -> RnfsNode: + """An offline node over /name that accepts /pin from IDENTITY.""" + config = Config(rnfs_dir=os.path.join(self.tmp.name, name), slow="no", lookup_timeout=5, path_timeout=0.5, + default_bootstrap=False, remote_pin_identities=[IDENTITY.hex()]) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork()) + self.net.nodes[node_id] = node + self.addCleanup(node.stop) + return node + + def start_with(self, entries) -> RnfsNode: + """Write entries as B's remote_pins.json, then open B and start it as RnfsNode.__init__ does.""" + state_dir = Config(rnfs_dir=os.path.join(self.tmp.name, "b")).state_dir + os.makedirs(state_dir, exist_ok=True) + with open(os.path.join(state_dir, REMOTE_PINS_FILE), "w", encoding="utf-8") as f: + json.dump(entries, f) + node = self.open("b", B_ID) + node.routing.heard(A_ID) + try: + node._start_threads() + except TypeError as error: + # NEW-6: fails until each saved entry's types are checked inside the try, so a bad one is skipped + self.fail(f"the node failed to start: {type(error).__name__}: {error}") + return node + + def test_unhashable_sources_are_skipped(self): + """Sources that are a JSON list or object are skipped and logged; the good entry is still fetched.""" + logged = [] + with mock.patch.object(node_module, "log", side_effect=lambda message, *args: logged.append(message)): + node = self.start_with([[[SOURCE], str(self.cid)], [{"s": SOURCE}, str(self.cid)], + [SOURCE, [str(self.cid)]], [SOURCE, {"cid": str(self.cid)}], + [SOURCE, str(self.cid)]]) + self.assertTrue(wait_for(lambda: SOURCE in node.storage.pins().get(self.cid, set()), 15), + "the good remote pin wasn't resumed") + self.assertTrue(any("4 malformed entries" in message and REMOTE_PINS_FILE in message + for message in logged), logged) + + +if __name__ == "__main__": + unittest.main() From 0e350ae1cf8811cf3e7ad7e51cd75dd055ce789f Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:00:04 +1030 Subject: [PATCH 072/107] Release stray follow and LXMF pins at start only when follow.json or the inbox was read whole, and record them again otherwise --- rnfs/features/follow.py | 57 ++++-- rnfs/features/lxmf.py | 58 +++++- .../test_r1_reconcile_damaged_state.py | 174 ++++++++++++++++++ 3 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 tests/security/test_r1_reconcile_damaged_state.py diff --git a/rnfs/features/follow.py b/rnfs/features/follow.py index 1cd797b..14283c5 100644 --- a/rnfs/features/follow.py +++ b/rnfs/features/follow.py @@ -74,30 +74,38 @@ def __init__(self, node): self._thread: threading.Thread | None = None self._keepers = node.feature("keepers") self._keepers.add_listener(self._changed) - self._load() - self._release_unfollowed() + self._reconcile(self._load()) # State - def _load(self): - """Read follow.json, skipping malformed entries.""" + def _load(self) -> bool: + """Read follow.json, skipping malformed entries; True if it was there and every entry was read.""" from rnfs.content.storage import read_json - state = read_json(self._path) + state = read_json(self._path) # a damaged file is moved aside and gives None, as a missing one does entries = state.get("names") if isinstance(state, dict) else None if not isinstance(entries, dict): - return + return False + clean = True for name_hex, entry in entries.items(): try: name = bytes.fromhex(name_hex) pinned = Cid.parse(entry["pinned"]) if entry.get("pinned") is not None else None except (AttributeError, KeyError, TypeError, ValueError): + clean = False continue if len(name) == ID_LENGTH: self._pinned[name] = pinned - - def _release_unfollowed(self): - """Release follow pins of names no longer followed: a crash inside unfollow, after follow.json was saved, - leaves one that nothing else would release (`pin rm` releases only the user's pins).""" + else: + clean = False + return clean + + def _reconcile(self, clean: bool): + """Make the follow pins match follow.json. If it was read whole (clean), release follow pins of names it + doesn't list: a crash inside unfollow, after follow.json was saved, leaves one that nothing else would + release (`pin rm` releases only the user's pins). Otherwise (missing, damaged or with a malformed entry) + such a pin is the record of a name still followed: follow it again, so it is kept, checked and can be + unfollowed.""" + held: dict[bytes, set[Cid]] = {} for cid, sources in self.node.storage.pins().items(): for source in sources: if not source.startswith(SOURCE_PREFIX): @@ -106,12 +114,29 @@ def _release_unfollowed(self): name = bytes.fromhex(source[len(SOURCE_PREFIX):]) except ValueError: continue - if name not in self._pinned: - _log(f"releasing {cid}, pinned for name {name.hex()}, which is no longer followed", LOG_WARNING) - try: - self.node.storage.unpin(cid, source) - except OSError as e: # e.g. a read-only state directory: tried again at the next start - _log(f"could not release {cid}: {e}", LOG_ERROR) + if len(name) == ID_LENGTH and name not in self._pinned: + held.setdefault(name, set()).add(cid) + if not held: + return + if not clean: + for name, cids in sorted(held.items()): + # One target is its pin; several (a crash during a check) are sorted out by its next check + self._pinned[name] = next(iter(cids)) if len(cids) == 1 else None + _log(f"{FOLLOW_FILE} is missing, damaged or incomplete: following name {name.hex()} again, " + f"as its pin of {', '.join(sorted(str(cid) for cid in cids))} shows", LOG_WARNING) + try: + self._save() + except OSError as e: # the pins are kept; follow.json is rebuilt again at the next start + _log(f"saving {FOLLOW_FILE} failed: {e}", LOG_ERROR) + return + for name, cids in held.items(): + source = follow_source(name) + for cid in cids: + _log(f"releasing {cid}, pinned for name {name.hex()}, which is no longer followed", LOG_WARNING) + try: + self.node.storage.unpin(cid, source) + except OSError as e: # e.g. a read-only state directory: tried again at the next start + _log(f"could not release {cid}: {e}", LOG_ERROR) def _save(self): """Write follow.json atomically (lock held).""" diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index 33a6d2b..85a2124 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -432,7 +432,7 @@ class MessageLog: At most limit records and max_bytes of JSON are kept. To make room, the oldest record protect() is false for is dropped; a protected one only when every other is protected too. add() may also name records that are dropped only for each other's (reserved). valid() checks records loaded from the file (malformed ones - are dropped). + are dropped; clean says whether the file was there and every record in it was read). """ def __init__(self, path: str, limit: int, max_bytes: int | None = None, protect=None, valid=None): @@ -448,10 +448,11 @@ def __init__(self, path: str, limit: int, max_bytes: int | None = None, protect= self._entries: list[dict] = [] self._sizes: dict[int, int] = {} # id -> bytes as JSON from rnfs.content.storage import read_json - stored = read_json(path) + stored = read_json(path) # a damaged file is moved aside and gives None, as a missing one does + self.clean = False # the file was there and every record in it was read if isinstance(stored, dict): entries = stored.get("messages") - bad, seen = 0, set() + bad, seen = 0 if isinstance(entries, list) else 1, set() for entry in entries if isinstance(entries, list) else []: if isinstance(entry, dict) and type(entry.get("id")) is int and entry["id"] > 0 \ and entry["id"] not in seen and (valid is None or valid(entry)): @@ -461,6 +462,7 @@ def __init__(self, path: str, limit: int, max_bytes: int | None = None, protect= bad += 1 if bad: _log(f"dropped {bad} malformed record(s) from {path}", LOG_WARNING) + self.clean = not bad self._entries = self._entries[-limit:] self._sizes = {e["id"]: _json_bytes(e) for e in self._entries} while self.max_bytes is not None and len(self._entries) > 1 and sum(self._sizes.values()) > max_bytes: @@ -758,19 +760,55 @@ def __init__(self, node): def _release_unrecorded(self): """Release lxmf: pins no inbox message records as pinned. Pins are saved at once and the inbox every few seconds, so a crash can leave a pin whose message is gone or still queued; nothing else would release - it (`pin rm` releases only the user's pins). A queued link is fetched and pinned again at start().""" + it (`pin rm` releases only the user's pins). A queued link is fetched and pinned again at start(). + + Only if the inbox file was read whole: when it is missing, damaged (moved aside) or had a malformed record, + such a pin may be one a lost message held. Then each sender's unrecorded pins are kept and recorded in a + new inbox message, so they are kept at later starts too and removing that message releases them.""" from rnfs.content.storage import LXMF_PREFIX recorded = {(entry.get("from"), result.get("cid")) for entry in self.inbox.entries() for result in entry.get("auto") or [] if isinstance(result, dict) and result.get("state") == "pinned"} + unrecorded: dict[str, list[Cid]] = {} for cid, sources in self.node.storage.pins().items(): for source in sources: if source.startswith(LXMF_PREFIX) and (source[len(LXMF_PREFIX):], str(cid)) not in recorded: - _log(f"releasing {cid}, pinned for a message from {source[len(LXMF_PREFIX):]} that the inbox " - "no longer records", LOG_WARNING) - try: - self.node.storage.unpin(cid, source) - except OSError as e: # e.g. a read-only state directory: tried again at the next start - _log(f"could not release {cid}: {e}", LOG_ERROR) + unrecorded.setdefault(source[len(LXMF_PREFIX):], []).append(cid) + if not self.inbox.clean: + for sender, cids in sorted(unrecorded.items()): + self._record_kept(sender, sorted(cids, key=str)) + return + for sender, cids in unrecorded.items(): + for cid in cids: + _log(f"releasing {cid}, pinned for a message from {sender} that the inbox no longer records", + LOG_WARNING) + try: + self.node.storage.unpin(cid, LXMF_PREFIX + sender) + except OSError as e: # e.g. a read-only state directory: tried again at the next start + _log(f"could not release {cid}: {e}", LOG_ERROR) + + def _record_kept(self, sender: str, cids: list[Cid]): + """Keep sender's lxmf: pins of cids, which an inbox that didn't load whole doesn't record: add an inbox + message from sender recording them as pinned, and save the inbox.""" + listed = ", ".join(str(cid) for cid in cids) + if _HEX32.fullmatch(sender) is None: # not a source this feature makes: kept, never recorded + _log(f"keeping {listed}, pinned for {sender!r}, which isn't an LXMF address", LOG_WARNING) + return + _log(f"the inbox file is missing, damaged or incomplete: keeping {listed}, pinned for a message from " + f"{sender} it no longer has, in a new inbox message (remove it to release them)", LOG_WARNING) + links = [Link(cid) for cid in cids] + entry = {"hash": "", "from": sender, "display_name": None, "sent": None, "received": time.time(), + "title": "", "method": "unknown", "links": [link.to_json() for link in links], + "text": "Restored by rnfs at start: the inbox file was missing or damaged, and these links were " + "pinned for a message from this sender that it no longer has. Remove this message to " + "release them.", + "auto": [{"state": "pinned", "cid": str(cid)} for cid in cids]} + with self._lock: + _, dropped = self.inbox.add(entry) + self._release(dropped) + try: + self.inbox.save() + except OSError as e: # the pins are kept; recorded again at the next start + _log(f"saving the inbox failed: {e}", LOG_ERROR) @staticmethod def _propagated_sent(entry: dict) -> bool: diff --git a/tests/security/test_r1_reconcile_damaged_state.py b/tests/security/test_r1_reconcile_damaged_state.py new file mode 100644 index 0000000..280b6e7 --- /dev/null +++ b/tests/security/test_r1_reconcile_damaged_state.py @@ -0,0 +1,174 @@ +"""R-1: the start-up reconcile released follow: and lxmf: pins when follow.json or the LXMF inbox was missing, +damaged or had a malformed entry. + +NEW-2 made the node release, at start, follow pins of names follow.json doesn't list and lxmf pins no inbox +message records. But a follow.json or lxmf_inbox.json that is missing, damaged (read_json moves it aside) or +holds a malformed entry lists nothing for the names and messages it lost, so a restart released pins of names +still followed and of messages still wanted. The fixed reconcile releases only when the file was read whole; +otherwise it keeps the pins and records them again (the name is followed again; the sender's pins get a new +inbox message), so they also survive later starts and can still be released by unfollowing or removing it. + +The follow tests use two offline nodes (A owns a name B follows) on KeeperNet, as test_new2_follow_lxmf_pins +does; the lxmf tests use tests.test_lxmf's FeatureTestCase. Each damages the state file between two starts. +""" +import io +import json +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.content.cid import Cid +from rnfs.features import lxmf +from rnfs.features.follow import FOLLOW_FILE, follow_source +from rnfs.node import RnfsNode +from tests.test_lxmf import SENDER, FeatureTestCase +from tests.test_names import FakeNetwork +from tests.test_names_keep import KeeperNet + +A_ID, B_ID = bytes([0xA] * 16), bytes([0xB] * 16) + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the nodes' log.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +def damage(path: str, how: str, malformed): + """Make the state file at path missing, damaged (truncated JSON) or hold one malformed entry: malformed(state) + changes the loaded state in place.""" + if how == "missing": + os.remove(path) + elif how == "damaged": + with open(path, "r+", encoding="utf-8") as f: + text = f.read() + f.seek(0) + f.truncate() + f.write(text[:len(text) // 2]) + else: + with open(path, encoding="utf-8") as f: + state = json.load(f) + malformed(state) + with open(path, "w", encoding="utf-8") as f: + json.dump(state, f) + + +class FollowDamagedStateTest(unittest.TestCase): + """B follows A's name; follow.json is then lost or damaged and B restarts.""" + + def setUp(self): + """Two nodes that know each other, A's name, and B following it at its first target.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.net = KeeperNet() + self.net.sender = B_ID + self.a = self.make_node("a", A_ID) + self.addCleanup(self.a.stop) + self.b = self.make_node("b", B_ID) + self.addCleanup(lambda: self.b.stop()) # whichever B is open last + self.a.routing.heard(B_ID) + self.b.routing.heard(A_ID) + self.name = bytes.fromhex(self.a.name_create("site")["name"]) + self.source = follow_source(self.name) + self.cid = Cid.parse(self.a.add_stream(io.BytesIO(b"version one"))["cid"]) + self.a.name_publish("site", str(self.cid)) + self.a.records.store(self.cid.key, A_ID) + self.b.names.offer(self.name, 1, self.cid, 0.0) + follow = self.b.feature("follow") + follow.follow(self.name) + self.assertTrue(follow.check(self.name, network=False)) + self.assertEqual(self.b.storage.pinned_by(self.source), {self.cid}) + self.path = os.path.join(self.b.config.state_dir, FOLLOW_FILE) + + def make_node(self, label: str, node_id: bytes) -> RnfsNode: + """An offline node on its own directory (a restart if it was used before).""" + config = Config(rnfs_dir=os.path.join(self.tmp.name, label), slow="no", lookup_timeout=5, path_timeout=0.2) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork()) + self.net.nodes[node_id] = node + return node + + def restart(self) -> RnfsNode: + """Stop B and open it again on the same directory.""" + self.b.stop() + self.b = self.make_node("b", B_ID) + self.b.routing.heard(A_ID) + return self.b + + def check_kept(self, how: str): + """After follow.json is made `how`, restarts keep the pin and follow the name again; unfollow releases.""" + self.b.stop() + damage(self.path, how, lambda state: state["names"][self.name.hex()].update(pinned="not-a-cid")) + b = self.restart() + # R-1: fails until follow pins are released at start only when follow.json was read whole + self.assertEqual(b.storage.pinned_by(self.source), {self.cid}, + f"a {how} {FOLLOW_FILE} released the pin of a name still followed") + self.assertEqual([entry["name"] for entry in b.feature("follow").following()], [self.name.hex()]) + b = self.restart() # follow.json was written again: the next start keeps the pin too + self.assertEqual(b.storage.pinned_by(self.source), {self.cid}) + self.assertEqual(b.feature("follow").following()[0]["pinned"], str(self.cid)) + b.feature("follow").unfollow(self.name) + self.assertEqual(b.storage.pinned_by(self.source), set()) + + def test_missing_follow_file_keeps_pins(self): + """follow.json deleted.""" + self.check_kept("missing") + + def test_damaged_follow_file_keeps_pins(self): + """follow.json truncated (moved aside as damaged at start).""" + self.check_kept("damaged") + + def test_malformed_follow_entry_keeps_pins(self): + """follow.json with the name's entry malformed (skipped at load).""" + self.check_kept("malformed") + + +class LxmfDamagedStateTest(FeatureTestCase): + """B auto-fetches and pins a link from a trusted sender; its inbox file is then lost or damaged.""" + + def check_kept(self, how: str): + """After lxmf_inbox.json is made `how`, the pin is kept and recorded; removing that record releases it.""" + feature = self.feature_node(lxmf_auto_fetch_from=[SENDER.hex()]) + kept = feature.received(self.link_message(number=1)) + feature.process_auto_fetches() + feature.save() + target = Cid.parse(feature.inbox.get(kept)["auto"][0]["cid"]) + source = f"lxmf:{SENDER.hex()}" + path = os.path.join(self.b.config.state_dir, "lxmf_inbox.json") + damage(path, how, lambda state: state["messages"][0].update(links="not a list")) + again = lxmf.LxmfFeature(self.b) + # R-1: fails until lxmf pins are released at start only when the inbox file was read whole + self.assertEqual(self.b.storage.pinned_by(source), {target}, + f"a {how} inbox file released the pin of a message the inbox lost") + records = [entry for entry in again.inbox_list() + if {"state": "pinned", "cid": str(target)} in (entry.get("auto") or [])] + self.assertEqual([entry["from"] for entry in records], [SENDER.hex()]) + self.assertEqual([lxmf.Link.from_json(link).cid for link in records[0]["links"]], [target]) + again = lxmf.LxmfFeature(self.b) # the record was saved: the next start keeps the pin too + self.assertEqual(self.b.storage.pinned_by(source), {target}) + self.assertEqual(again.inbox_rm(records[0]["id"]), 1) + self.assertEqual(self.b.storage.pinned_by(source), set()) + + def test_missing_inbox_keeps_pins(self): + """lxmf_inbox.json deleted.""" + self.check_kept("missing") + + def test_damaged_inbox_keeps_pins(self): + """lxmf_inbox.json truncated (moved aside as damaged at start).""" + self.check_kept("damaged") + + def test_malformed_inbox_record_keeps_pins(self): + """lxmf_inbox.json with the message's record malformed (dropped at load).""" + self.check_kept("malformed") + + +if __name__ == "__main__": + unittest.main() From 56ed5ac69891f3023ea76c7d8953d4f1426308f6 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:00:04 +1030 Subject: [PATCH 073/107] Keep the pins and names.json entry of a name whose key file can't be read at start --- rnfs/names.py | 31 +++-- tests/security/test_r2_name_key_unreadable.py | 118 ++++++++++++++++++ 2 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 tests/security/test_r2_name_key_unreadable.py diff --git a/rnfs/names.py b/rnfs/names.py index 73ee414..228c33f 100644 --- a/rnfs/names.py +++ b/rnfs/names.py @@ -255,6 +255,10 @@ def __init__(self, config: Config, pins, network=None, clock=time.time, monotoni self.keepers = None # Pins of names whose key file was deleted by hand, released by release_gone() once the node is set up self._gone: list[tuple[str, Cid]] = [] + # Aliases whose key file is there but couldn't be read (permissions, damage): not names this node can + # use, but not gone either, so their pins stay and their names.json entry (or None) is written back as + # it was, for when the file can be read again + self._unreadable: dict[str, dict | None] = {} # Aliases with an entry in names.json: for these the recorded target (or none) is the only CID their pin # source may hold (reconcile_pins). A key without one (names.json lost) may have pins nothing records self._recorded: set[str] = set() @@ -349,7 +353,8 @@ def reconcile_pins(self, pins: dict[Cid, set[str]]): could leave one that `pin rm` and `name rm` couldn't remove). A key with no names.json entry (the file was lost) keeps its pins until its next publish or removal, - which release them; a pin of an alias with no key at all is released. + which release them; a pin of an alias with no key file at all is released, but one whose key file is + there and can't be read is kept. """ self.release_gone() with self._lock: @@ -360,6 +365,9 @@ def reconcile_pins(self, pins: dict[Cid, set[str]]): held.setdefault(source[len(PIN_SOURCE_PREFIX):], set()).add(cid) for alias in sorted(set(held) | set(self._keys)): cids = held.get(alias, set()) + if alias in self._unreadable: + log(f"keeping the pins of name {alias}, whose key file can't be read", LOG_WARNING) + continue if alias not in self._keys: log(f"releasing the pins of name {alias}, which has no key", LOG_WARNING) self._release(alias, cids) @@ -853,11 +861,12 @@ def _load_keys(self): return for alias in entries: path = self._key_path(alias) - if not _ALIAS.fullmatch(alias) or not os.path.isfile(path): - continue - identity = RNS.Identity.from_file(path) + if not _ALIAS.fullmatch(alias) or not (os.path.isfile(path) or os.path.islink(path)): + continue # not a key file (a directory, a FIFO, ...) + identity = RNS.Identity.from_file(path) if os.path.isfile(path) else None # None: a dangling symlink if identity is None: - log(f"could not read name key {path}; skipping it", LOG_ERROR) + log(f"could not read name key {path}; skipping it, and keeping its pins and records", LOG_ERROR) + self._unreadable[alias] = None continue name = name_for_identity(identity) if name in self._aliases: @@ -885,6 +894,8 @@ def _load_state(self): if alias in self._keys: self._published[alias] = published self._recorded.add(alias) + elif alias in self._unreadable: + self._unreadable[alias] = entry # written back unchanged by _save elif cid is not None: # Its key file was deleted by hand: nothing will republish it, so release its pin self._gone.append((alias, cid)) @@ -934,11 +945,15 @@ def _save_floor(self): def _save(self): """Write published and held records atomically.""" + published = {alias: {"sequence": p.sequence, "cid": str(p.cid) if p.cid is not None else None, + "imported": p.imported} + for alias, p in self._published.items()} + for alias, entry in self._unreadable.items(): + if entry is not None and alias not in published: + published[alias] = entry state = { "version": STATE_VERSION, - "published": {alias: {"sequence": p.sequence, "cid": str(p.cid) if p.cid is not None else None, - "imported": p.imported} - for alias, p in sorted(self._published.items())}, + "published": dict(sorted(published.items())), "held": {name.hex(): {"sequence": r.sequence, "cid": str(r.cid), "heard": r.heard} for name, r in sorted(self._held.items())}, } diff --git a/tests/security/test_r2_name_key_unreadable.py b/tests/security/test_r2_name_key_unreadable.py new file mode 100644 index 0000000..8703ffa --- /dev/null +++ b/tests/security/test_r2_name_key_unreadable.py @@ -0,0 +1,118 @@ +"""R-2: a name key file that exists but can't be read at start was treated as deleted, releasing the name's pin +and dropping its names.json record. + +NameManager skipped a key file RNS couldn't load (no permission, damaged), so its alias had no key; the +names.json entry then counted as a key "deleted by hand" (release_gone() released the pin and the save dropped +the entry) and reconcile_pins released any pin of an alias with no key. Once the file could be read again the +name was back with no target and sequence 0. The fixed manager treats only a missing key file as removed: an +unreadable one keeps its pins, and its names.json entry is written back unchanged by every save. + +Offline node (RnfsNode.offline over tests.test_names' FakeNetwork), restarted on the same directory. +""" +import io +import json +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.content.cid import Cid +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, NetClient + +_saved_loglevel = RNS.loglevel + + +def setUpModule(): + """Silence the node log while these tests run.""" + RNS.loglevel = RNS.LOG_CRITICAL + + +def tearDownModule(): + """Restore RNS's loglevel.""" + RNS.loglevel = _saved_loglevel + + +class UnreadableNameKeyTest(unittest.TestCase): + """A node publishes name "site", then restarts while its key file can't be read.""" + + def setUp(self): + """The node, with "site" published at a target pinned only by the name.""" + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.config = Config(rnfs_dir=os.path.join(self.tmp.name, "a"), slow="no") + self.node = self.open() + self.cid = Cid.parse(self.node.add_stream(io.BytesIO(b"site v1"), pin=False)["cid"]) + self.node.names.create("site") + self.node.names.publish("site", self.cid) + self.key = self.node.names._key_path("site") + self.state = os.path.join(self.config.state_dir, "names.json") + self.recorded = self.published()["site"] + self.assertEqual(self.recorded["sequence"], 1) + + def open(self) -> RnfsNode: + """The node, opened (again) on its directory.""" + net = NetClient() + node = RnfsNode.offline(self.config, A_ID, net, FakeNetwork()) + net.nodes[A_ID] = node + self.addCleanup(node.stop) + return node + + def restart(self) -> RnfsNode: + """Stop the node and open it again.""" + self.node.stop() + self.node = self.open() + return self.node + + def published(self) -> dict: + """names.json's published entries.""" + with open(self.state, encoding="utf-8") as f: + return json.load(f)["published"] + + def check_kept(self, make_unreadable, make_readable): + """While the key can't be read the pin and record stay (across a save); once readable, the name is back.""" + make_unreadable() + node = self.restart() + # R-2: fails until only a missing key file counts as a removed name + self.assertEqual(node.storage.pinned_by("name:site"), {self.cid}, + "an unreadable key file released the name's pin") + node.names.create("other") # saves names.json + self.assertEqual(self.published().get("site"), self.recorded, "the name's record was dropped") + make_readable() + node = self.restart() + self.assertEqual(node.storage.pinned_by("name:site"), {self.cid}) + site = next(entry for entry in node.names.list() if entry["alias"] == "site") + self.assertEqual((site["cid"], site["sequence"]), (str(self.cid), 1)) + + @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root reads files without permission") + @unittest.skipIf(os.name == "nt", "chmod can't make a file unreadable on Windows") + def test_key_without_read_permission(self): + """The key file's permissions taken away, then given back.""" + self.addCleanup(os.chmod, self.key, 0o600) + self.check_kept(lambda: os.chmod(self.key, 0), lambda: os.chmod(self.key, 0o600)) + + def test_damaged_key(self): + """The key file's bytes replaced (too short to be a key), then restored.""" + with open(self.key, "rb") as f: + good = f.read() + + def write(data): + """Write data as the key file.""" + with open(self.key, "wb") as f: + f.write(data) + + self.check_kept(lambda: write(b"\x00" * 7), lambda: write(good)) + + def test_deleted_key_still_releases(self): + """A key file deleted by hand is still a removed name: its pin is released and its record dropped.""" + os.remove(self.key) + node = self.restart() + self.assertEqual(node.storage.pinned_by("name:site"), set()) + self.assertNotIn("site", self.published()) + + +if __name__ == "__main__": + unittest.main() From 1d7874ef98d2fc42c5be447a74f2f010fa3d2b0b Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:00:04 +1030 Subject: [PATCH 074/107] Document the start-up reconcile with damaged state, unreadable name keys and malformed remote pin entries --- CHANGELOG.md | 11 +++++++++-- design.md | 9 ++++++--- specification.md | 5 +++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f39555..c2f3950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ Fixes: until a restart, its pins queuing up and then being refused. - Remote pins accepted but not yet fetched survive a restart: they are saved in `state/remote_pins.json` and fetched again at start, unless their identity has left `remote_pin_identities`. They used to be dropped - silently. + silently. An entry of the file that isn't a pair of strings is skipped with a warning; it used to stop the + node from starting. Local API and privacy: - `GET /api/status` without the API token answers only `{"version": ...}`, enough for health checks; the node @@ -182,9 +183,15 @@ Publishing, names and storage: recorded at a target no longer pinned (which `gc` deleted) and the new one pinned by `name:`, which `pin rm` refused and `name rm` didn't release. At start the node now makes `name:` pins match `names.json`, releases `follow:` pins of names no longer followed and `lxmf:` pins no saved inbox message records, which - a crash inside `name unfollow` or before the inbox was saved used to leave for good. A followed name's check + a crash inside `name unfollow` or before the inbox was saved used to leave for good. Those two are released + only if `follow.json` or the inbox file was read whole: when it is missing, damaged (moved aside) or has a + malformed entry, the pins are kept and recorded again, the `follow:` pin's name being followed again and a + sender's `lxmf:` pins listed in a new inbox message, whose removal releases them. A followed name's check releases every other target its pin source holds. A pin or unpin whose `pins.json` save fails no longer stays changed in memory. +- A name key file that is there but can't be read at start (no permission, damaged) no longer counts as a + deleted key: the name's pins and its `names.json` entry are kept, so it is back as it was once the file can + be read. Its pins used to be released and its entry dropped, leaving the name at sequence 0 with no target. - Eviction and `gc` read the DAG nodes they walk before taking the storage lock, so serving `/blocks` no longer waits while the first eviction after a start reads every stored DAG node (about 0.4 s with 20 000 blocks); what to delete is still decided under the lock, so nothing that became reachable meanwhile is deleted. diff --git a/design.md b/design.md index a961cf4..164f607 100644 --- a/design.md +++ b/design.md @@ -625,9 +625,12 @@ Everything in the specification is implemented, with these simplifications and a features are set up, `reconcile_pins(storage.pins())` releases the pins of name keys deleted by hand (`release_gone()`), pins a recorded target again if its pin is missing, and releases `name:` pins nothing records; a key without a `names.json` entry keeps its pins until its next publish or removal (`_stale`, - where a release that failed with `OSError` also waits). `Storage.pin`/`unpin` undo their change in memory - if saving `pins.json` fails. Follow and LXMF do the same for their sources at start - (`_release_unfollowed`, `_release_unrecorded`). The name table marks + where a release that failed with `OSError` also waits). A key file that is there but can't be loaded is + `_unreadable`: its pins are kept and its `names.json` entry is written back as loaded. `Storage.pin`/`unpin` + undo their change in memory if saving `pins.json` fails. Follow and LXMF do the same for their sources at + start (`FollowFeature._reconcile`, `_release_unrecorded`), but release only if their file loaded whole + (`MessageLog.clean`); otherwise unrecorded pins are recorded again (the name followed again, or an inbox + message per sender listing them), so a later start keeps them too. The name table marks entries that were only heard (`_heard_only`, its own LRU, so finding one to evict is O(1)); an announce with no waiting resolve, of a name without a kept record, may only evict one of those. A resolve with `persist=False` (gateway, LXMF auto-fetch) raises the name's floor (`_floor`), which `_offer` checks. diff --git a/specification.md b/specification.md index 72d1962..d5b59ea 100644 --- a/specification.md +++ b/specification.md @@ -657,6 +657,11 @@ and repeat. match `names.json` (pinning a recorded target again, releasing the rest), and releases `follow:` pins of names no longer followed and `lxmf:` pins no saved inbox message records (a queued auto-fetch is fetched again). A followed name's check releases every other target its source holds. +- That release needs the state read whole. If `follow.json` or the inbox file is missing, damaged (moved + aside as `.damaged`) or holds a malformed entry, such pins are kept and recorded again: a `follow:` pin's + name is followed again, and a sender's `lxmf:` pins get a new inbox message listing them, whose removal + releases them. A name key file that is there but can't be read keeps its name's pins and `names.json` + entry; only a missing key file counts as a removed name. - A pin is provided only once it is complete. Blocks of fetches, adds and imports in progress, and of queued or running jobs (section 11), are protected as if pinned. From 126063ded20e9073dd57b1d04608d41770b2cff4 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:39 +1030 Subject: [PATCH 075/107] Treat a shared instance's local interface as of unknown bitrate in the /blocks grace, price hops past the first as slow unless the link's MTU was raised, and cover parts queued on the node's own slow radio --- rnfs/network/server.py | 63 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/rnfs/network/server.py b/rnfs/network/server.py index dfd89ec..dc0857d 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -51,6 +51,10 @@ # sized to Link.MDU), so it takes at most this long per hop at the interface's bitrate ADVERTISEMENT_BITS = 500 * 8 MAX_GRACE_HOPS = 32 # the requester can raise the hop count it reports; the grace is capped anyway +# What may be queued ahead of an advertisement on the node's own slow radio, per other response on that interface: +# one window of parts (RNS.Resource.WINDOW_MAX_SLOW, 10, of at most one 500-byte packet each). With the radio's +# default flow control off, RNS hands packets to the device at once, so when one actually leaves can't be read +QUEUED_WINDOW_BITS = 10 * 500 * 8 # Progress floor on a directly connected link: 1/1000 of the interface's bitrate (1.25 kB/s on a 10 Mbit/s TCP # interface). Far below what an honest transfer gets from a direct neighbour, but a requester trickling below it # loses its protection. Behind more hops the path may be much slower than our interface, so only the @@ -137,11 +141,14 @@ def link_path(link) -> tuple[object | None, float | None, int | None]: RNS 1.5.6 dependency: Link.attached_interface (the interface the link request arrived on), Interface.bitrate (the node's own configuration, or the interface's own guess) and Link.expected_hops (the hop count of the requester's RTT packet, which the requester can raise but transport nodes only increase). Read with - defaults, so a changed RNS falls back to the conservative values the callers use for unknowns. + defaults, so a changed RNS falls back to the conservative values the callers use for unknowns. Attached to a + shared instance, every link arrives on the local interface to it, whose bitrate (1 Gbit/s) says nothing + about the path, so the bitrate is unknown (via_shared_instance). """ interface = getattr(link, "attached_interface", None) bitrate = getattr(interface, "bitrate", None) - if type(bitrate) not in (int, float) or not math.isfinite(bitrate) or bitrate <= 0: + if type(bitrate) not in (int, float) or not math.isfinite(bitrate) or bitrate <= 0 \ + or via_shared_instance(interface): bitrate = None hops = getattr(link, "expected_hops", None) if type(hops) is not int or hops < 0: @@ -149,16 +156,46 @@ def link_path(link) -> tuple[object | None, float | None, int | None]: return interface, bitrate, hops -def response_grace(bitrate: float | None, hops: int | None, config: Config) -> float: +def via_shared_instance(interface) -> bool: + """Whether interface is a local one between RNS instances on this machine: the client end of the connection + to a shared instance (or, in the instance, a local client's), not a link to the network. + + RNS 1.5.6 dependency: Transport.interface_to_shared_instance tells the interface to the shared instance by + its is_connected_to_shared_instance attribute (LocalClientInterface sets it; the spawned server-side ends set + it False). The class names are a fallback. + """ + return interface is not None and (hasattr(interface, "is_connected_to_shared_instance") + or type(interface).__name__ in rates.LOCAL_INTERFACE_TYPES) + + +def fast_path(link) -> bool: + """Whether RNS raised the link's MTU above rates.SLOW_MTU, which it does only if every interface on the path + autoconfigures its MTU (from 62.5 kbit/s up, Interface.optimise_mtu) or has a fixed one: no LoRa or other + KISS radio on it. A requester can lower its link's MTU, never raise it past what the path allows.""" + try: + mtu = link.get_mtu() + except Exception: + return False + return type(mtu) is int and mtu > rates.SLOW_MTU + + +def response_grace(bitrate: float | None, hops: int | None, config: Config, fast: bool = False, + queued_bits: float = 0) -> float: """Seconds a new response is protected from displacement before it must show progress (section 3). - Enough for the advertisement to reach the requester and its first part request to come back over hops hops - at the receiving interface's bitrate (unmeasured_bitrate if unknown), plus GRACE_BASE, at most - deadline_slack. E.g. 3 hops at 1 kbit/s: 3 + 2 × 3 × 4 s = 27 s; any hop count over TCP: about 3 s. + Enough for the advertisement to reach the requester and its first part request to come back over hops hops, + plus GRACE_BASE, at most deadline_slack. The first hop is priced at the receiving interface's bitrate; the + others, which may be much slower, at slow_bitrate on a fast path (fast_path) and unmeasured_bitrate + otherwise, as is an unknown bitrate. queued_bits is what may be queued ahead of the advertisement on the + node's own interface. E.g. 1 hop at 1 kbit/s: 3 + 2 × 4 s = 11 s; 3 hops behind a TCP interface with a LoRa + segment, or anything at an unknown bitrate off a fast path: deadline_slack; any hop count on a fast path + over TCP: 3-5 s. """ - rate = bitrate if bitrate else config.unmeasured_bitrate + beyond = config.slow_bitrate if fast else config.unmeasured_bitrate + rate = bitrate if bitrate else beyond hops = min(max(1, hops or 1), MAX_GRACE_HOPS) - return min(config.deadline_slack, GRACE_BASE + 2 * hops * ADVERTISEMENT_BITS / rate) + one_way = ADVERTISEMENT_BITS / rate + (hops - 1) * ADVERTISEMENT_BITS / min(rate, beyond) + return min(config.deadline_slack, GRACE_BASE + 2 * one_way + queued_bits / rate) def progress_floor(bitrate: float | None, hops: int | None, config: Config) -> float: @@ -804,7 +841,8 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): rate = self.rates.get(link_id) slow = rates.link_is_slow(link, rate, self.config) interface, bitrate, hops = link_path(link) - grace = response_grace(bitrate, hops, self.config) + fast = fast_path(link) + grace = response_grace(bitrate, hops, self.config, fast, self._queued_bits(interface, bitrate)) # Until it is handed to RNS, the grace also covers the pacing wait response, displaced = self.pools.reserve(SLOW_POOL if slow else FAST_POOL, link_id, request_id, grace=min(self.config.deadline_slack, grace + wait), @@ -882,6 +920,13 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): else: self.incoming.finish_request(link_id, blocks=True) + def _queued_bits(self, interface, bitrate: float | None) -> float: + """What may be queued ahead of a new response's advertisement on the node's own interface, if it is a slow + one (below slow_bitrate): a window of parts for each response in flight on it (QUEUED_WINDOW_BITS).""" + if interface is None or not bitrate or bitrate >= self.config.slow_bitrate: + return 0 + return QUEUED_WINDOW_BITS * sum(1 for r in self.pools.responses() if r.interface is interface) + @staticmethod def _pace(response: Response, wait: float): """Hold an accepted response back wait seconds (at most MAX_PACING_WAIT) for upload pacing, or until From c6f4a74de3176709b28fe7607baa087377dad06e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:39 +1030 Subject: [PATCH 076/107] Let the per-interface fairness rule evict only responses past half their grace, once per grace per interface, and stop requesters renewing their own /blocks slots as the slowest response --- rnfs/network/server.py | 76 +++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/rnfs/network/server.py b/rnfs/network/server.py index dc0857d..294a2c0 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -60,6 +60,9 @@ # loses its protection. Behind more hops the path may be much slower than our interface, so only the # unmeasured_bitrate floor applies there FLOOR_FRACTION = 1 / 1000 +# A response displaced for a newcomer while making progress at less than this fraction of the rate it needs to +# be done when it is due (Response.due) counts as making none: its requester cools down +FAR_BEHIND = 1 / 4 COOLDOWN_MEMORY = 4096 # links and identities on a /blocks cooldown remembered at most # Requests per incoming link (section 3): a token bucket of REQUEST_BURST, refilled at REQUEST_RATE a second. # An honest requester waits for its answers, with at most MAX_REQUESTS_PER_LINK in progress, so it sends at most @@ -221,6 +224,9 @@ def __init__(self, link_id: bytes, request_id: bytes, pool: str, started: float) self.started = started self.size: int | None = None # packed bytes, once built self.deadline = float("inf") # absolute time by which it must have finished + # When it should be done judging from its path: its deadline, or for one with no rate estimate on a fast + # path (fast_path), the deadline it would have at slow_bitrate, which every interface on that path beats + self.due = float("inf") self.sent = 0.0 # bytes sent so far, from the Resource's progress self.resource = None # the RNS Resource carrying it, once found self.find_until = float("inf") @@ -274,6 +280,16 @@ def protected(self, now: float) -> bool: """Whether no newcomer may displace it for making no progress: within its grace, or moving.""" return self.in_grace(now) or self.moving(now) + def needed_rate(self, now: float) -> float: + """Bytes per second it must send from now on to be done when it is due (0 if its size isn't known yet).""" + if self.size is None or self.untracked: + return 0.0 + return max(0.0, self.size - self.sent) / max(min(self.due, self.deadline) - now, 1.0) + + def far_behind(self, now: float) -> bool: + """Whether it is moving at less than FAR_BEHIND of the rate it needs to be done when it is due.""" + return self.progress_rate(now) < FAR_BEHIND * self.needed_rate(now) + class ResponsePools: """Slots for /blocks responses: max_responses // 2 for slow links, the rest for others. Thread-safe. @@ -283,8 +299,10 @@ class ResponsePools: - the response making least progress among those past their grace and below their progress floor (it is marked idle, and its requester cools down); - else, if the newcomer's interface holds no slot in the pool, the oldest response with nothing sent from - an interface holding two or more, even within its grace; - - else the pool's response with the lowest throughput, if it is older than deadline_slack and past its grace. + an interface holding two or more that is past half its grace, at most once per grace per interface; + - else, if neither the newcomer's link nor its identity holds a slot in the pool, the pool's response with the + lowest throughput, if it is older than deadline_slack and past its grace (marked idle if it is far behind + the rate it needs to be done when it is due). Otherwise it is refused. """ @@ -294,20 +312,23 @@ def __init__(self, max_responses: int, deadline_slack: float, clock: Callable[[] self.deadline_slack = deadline_slack self.clock = clock self.active: dict[str, list[Response]] = {SLOW_POOL: [], FAST_POOL: []} + # id(interface) -> when it last lost a response to a newcomer from another interface + self.fairness_at: dict[int, float] = {} self.lock = threading.Lock() def reserve(self, pool: str, link_id: bytes, request_id: bytes, grace: float | None = None, floor: float = 0.0, - interface=None) -> tuple[Response | None, Response | None]: + interface=None, identity: bytes | None = None) -> tuple[Response | None, Response | None]: """Take a slot in pool: (new response, or None if refused; displaced response to cancel, or None). - grace (default deadline_slack), floor and interface are the new response's (section 3). + grace (default deadline_slack), floor, interface and identity (its requester's identity hash, if it + identified) are the new response's (section 3). """ with self.lock: now = self.clock() members = self.active[pool] displaced = None if len(members) >= self.capacity[pool]: - displaced = self._displaceable(members, link_id, interface, now) + displaced = self._displaceable(members, link_id, interface, identity, now) if displaced is None: return None, None members.remove(displaced) @@ -315,11 +336,14 @@ def reserve(self, pool: str, link_id: bytes, request_id: bytes, grace: float | N response.grace = self.deadline_slack if grace is None else grace response.floor = floor response.interface = interface + response.identity = identity members.append(response) return response, displaced - def _displaceable(self, members: list[Response], link_id: bytes, interface, now: float) -> Response | None: - """The member a newcomer on link_id arriving on interface may displace, or None. Caller holds the lock.""" + def _displaceable(self, members: list[Response], link_id: bytes, interface, identity: bytes | None, + now: float) -> Response | None: + """The member a newcomer on link_id (of identity) arriving on interface may displace, or None. Caller + holds the lock.""" others = [r for r in members if r.link_id != link_id] idle = [r for r in others if not r.protected(now)] if idle: @@ -327,16 +351,36 @@ def _displaceable(self, members: list[Response], link_id: bytes, interface, now: displaced.idle = True return displaced if interface is not None and all(r.interface is not interface for r in members): - held = Counter(id(r.interface) for r in members if r.interface is not None) - crowded = [r for r in others if r.interface is not None and held[id(r.interface)] >= 2 - and r.sent <= 0 and not r.untracked] - if crowded: - return min(crowded, key=lambda r: r.started) + displaced = self._fairness_pick(others, now) + if displaced is not None: + return displaced + # The slowest moving response, for a newcomer holding nothing in the pool: a requester can't keep its own + # responses young by displacing them from its own link or identity + if any(r.link_id == link_id or (identity is not None and r.identity == identity) for r in members): + return None slowest = min(members, key=lambda r: r.throughput(now)) - if slowest.link_id == link_id or now - slowest.started <= self.deadline_slack or slowest.in_grace(now): + if now - slowest.started <= self.deadline_slack or slowest.in_grace(now): return None + # Far behind what it needs to finish in time: no better than making no progress, so its requester cools + # down (else one taking a little of each response could renew its slots this way from a spare link) + slowest.idle = slowest.far_behind(now) return slowest + def _fairness_pick(self, others: list[Response], now: float) -> Response | None: + """For a newcomer from an interface holding no slot: the oldest response with nothing sent from an + interface holding two or more, once it is past half its grace and that interface hasn't lost one this + way within the response's grace. Caller holds the lock.""" + self.fairness_at = {key: at for key, at in self.fairness_at.items() if now - at <= self.deadline_slack} + held = Counter(id(r.interface) for r in others if r.interface is not None) + crowded = [r for r in others if r.interface is not None and held[id(r.interface)] >= 2 + and r.sent <= 0 and not r.untracked and now - r.grace_start > r.grace / 2 + and now - self.fairness_at.get(id(r.interface), -math.inf) > r.grace] + if not crowded: + return None + displaced = min(crowded, key=lambda r: r.started) + self.fairness_at[id(displaced.interface)] = now + return displaced + def release(self, response: Response) -> bool: """Free response's slot. True if it held one.""" with self.lock: @@ -847,9 +891,7 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): response, displaced = self.pools.reserve(SLOW_POOL if slow else FAST_POOL, link_id, request_id, grace=min(self.config.deadline_slack, grace + wait), floor=progress_floor(bitrate, hops, self.config), - interface=interface) - if response is not None: - response.identity = identity + interface=interface, identity=identity) if displaced is not None: self._cancel(displaced, "made no progress; displaced by a new request" if displaced.idle else "displaced by a new request") @@ -902,6 +944,8 @@ def _handle_blocks(self, path, data, request_id, link_id, remote_identity): return body # nothing to track response.size = packed response.deadline = response.started + rates.response_deadline(packed, rate, self.config) + response.due = response.started + rates.response_deadline( + packed, rate or (self.config.slow_bitrate if fast else None), self.config) response.grace = grace self._restart_grace(response) response.find_until = self.clock() + REGISTER_WINDOW From 86c44756913df3e2f396d227b1d60c1fe1c0123a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:39 +1030 Subject: [PATCH 077/107] Give links that aren't slow 16 /blocks slots by default (fast_blocks_responses) and cool down for 300 s --- rnfs/config.py | 11 +++++++---- rnfs/network/server.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/rnfs/config.py b/rnfs/config.py index 24564d6..96bf39d 100644 --- a/rnfs/config.py +++ b/rnfs/config.py @@ -20,8 +20,9 @@ WHOLE_NUMBERS = { # name: (minimum, maximum or None). gateway_port 0: any free port (printed at start) "api_port": (1, 65535), "gateway_port": (0, 65535), "verbosity": (0, None), "min_rate_sample": (1, None), "beta": (1, None), "max_failures": (1, None), "max_incoming_links": (1, None), "max_blocks_responses": (2, None), - "max_records": (0, None), "max_storage": (0, None), "max_output_entries": (0, None), - "name_table_size": (0, None), "remote_pin_quota": (0, None), "gateway_fetches": (0, None), + "fast_blocks_responses": (1, None), "max_records": (0, None), "max_storage": (0, None), + "max_output_entries": (0, None), "name_table_size": (0, None), "remote_pin_quota": (0, None), + "gateway_fetches": (0, None), } OPTIONAL_WHOLE_NUMBERS = {"alpha": 1, "max_parallel_providers": 1, "max_download_size": 0} # name: minimum RATES = ("slow_bitrate", "unmeasured_bitrate", "max_upload_rate") # bits/s, above 0 @@ -74,13 +75,15 @@ class Config: deadline_slack: float = 30 stall_timeout: float = 120 # /blocks refused to a link (and identity) whose response was displaced or cancelled for making no progress - blocks_cooldown: float = 60 + blocks_cooldown: float = 300 lookup_timeout: float = 600 path_timeout: float = 30 max_failures: int = 3 link_idle_timeout: float = 300 max_incoming_links: int = 128 - max_blocks_responses: int = 4 # at least 2 + max_blocks_responses: int = 4 # at least 2: half of them for slow links, the rest for others + # Responses in progress at once on links that aren't slow, if more than the rest of max_blocks_responses + fast_blocks_responses: int = 16 max_upload_rate: float | None = None # none; half the rate estimate on slow links that have one max_records: int = 100_000 ban_time: float = 1 * HOUR diff --git a/rnfs/network/server.py b/rnfs/network/server.py index 294a2c0..a204f45 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -292,7 +292,8 @@ def far_behind(self, now: float) -> bool: class ResponsePools: - """Slots for /blocks responses: max_responses // 2 for slow links, the rest for others. Thread-safe. + """Slots for /blocks responses: max_responses // 2 for slow links, the rest (or fast_responses, if more) for + others. Thread-safe. Each pool competes only with itself. When a pool is full, a new response may displace (section 3), never a response on its own link: @@ -306,9 +307,11 @@ class ResponsePools: Otherwise it is refused. """ - def __init__(self, max_responses: int, deadline_slack: float, clock: Callable[[], float] = time.monotonic): + def __init__(self, max_responses: int, deadline_slack: float, clock: Callable[[], float] = time.monotonic, + fast_responses: int | None = None): """Empty pools.""" - self.capacity = {SLOW_POOL: max_responses // 2, FAST_POOL: max_responses - max_responses // 2} + slow = max_responses // 2 + self.capacity = {SLOW_POOL: slow, FAST_POOL: max(max_responses - slow, fast_responses or 0)} self.deadline_slack = deadline_slack self.clock = clock self.active: dict[str, list[Response]] = {SLOW_POOL: [], FAST_POOL: []} @@ -633,7 +636,8 @@ def __init__(self, destination: RNS.Destination, config: Config, backend: Backen self.config = config self.backend = backend self.clock = clock - self.pools = ResponsePools(config.max_blocks_responses, config.deadline_slack, clock) + self.pools = ResponsePools(config.max_blocks_responses, config.deadline_slack, clock, + config.fast_blocks_responses) self.incoming = IncomingLinks(config.max_incoming_links, clock) self.pacer = UploadPacer(clock) self.cooldowns = Cooldowns(config.blocks_cooldown, clock) From 1af981f4c4d2c6dc367cbe05afb719fc4524bca0 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:39 +1030 Subject: [PATCH 078/107] Count a /blocks response's upload_budget commitment after its grace only up to what it sends at its rate before its deadline --- rnfs/network/server.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/rnfs/network/server.py b/rnfs/network/server.py index a204f45..cd91c3d 100644 --- a/rnfs/network/server.py +++ b/rnfs/network/server.py @@ -290,6 +290,17 @@ def far_behind(self, now: float) -> bool: """Whether it is moving at less than FAR_BEHIND of the rate it needs to be done when it is due.""" return self.progress_rate(now) < FAR_BEHIND * self.needed_rate(now) + def commitment(self, now: float) -> int: + """Budget bytes it may still send that haven't been charged (upload_budget, section 3): its uncharged rest + while it is within its grace; after that, at most what it sends at its progress rate before its deadline, + and nothing if it is below its floor (or has sent nothing).""" + rest = max(0, self.cost - self.charged) + if self.in_grace(now): + return rest + if not self.moving(now): + return 0 + return int(min(rest, self.progress_rate(now) * max(0.0, self.deadline - now))) + class ResponsePools: """Slots for /blocks responses: max_responses // 2 for slow links, the rest (or fast_responses, if more) for @@ -1100,14 +1111,15 @@ def _admit(self, response: Response, cost: int, reservation: int) -> bool: def _committed(self, response: Response | None = None) -> int: """Budget bytes the responses in flight (other than response) are committed to send but haven't been - charged: each one's unsent rest while it is within its grace or has sent something. A response past its - grace with nothing sent (one its requester isn't fetching) commits nothing.""" + charged (Response.commitment): each one's unsent rest while it is within its grace, and after that what + it can send at its progress rate before its deadline. One past its grace with nothing sent (its requester + isn't fetching it) or crawling below its floor commits nothing, so a few such responses can't hold + admission while spending nothing.""" if self.airtime.bucket is None: return 0 now = self.clock() - return sum(max(0, other.cost - other.charged) for other in self.pools.responses() - if other is not response and not other.finished and not other.settled and other.cost - and (other.in_grace(now) or other.sent > 0)) + return sum(other.commitment(now) for other in self.pools.responses() + if other is not response and not other.finished and not other.settled and other.cost) def _charge_progress(self, response: Response): """Charge upload_budget for what response has sent since it was last charged.""" From 9fc2a357b16beed3d230e346e0d5e4842849c7d0 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:46 +1030 Subject: [PATCH 079/107] Update the H1 slot tests for the 16-slot fast pool, the fairness rule's half grace and slow pricing of further hops --- tests/security/test_h1_response_slots.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/security/test_h1_response_slots.py b/tests/security/test_h1_response_slots.py index 4b4b0a1..da454d9 100644 --- a/tests/security/test_h1_response_slots.py +++ b/tests/security/test_h1_response_slots.py @@ -5,7 +5,8 @@ measures from the interface and hop count its request came over, and after that only while it makes progress at a floor; a requester whose response was displaced or cancelled for making no progress cools down, stalled responses are cancelled and refunded, and a newcomer on an interface holding no slot may displace a response -that has sent nothing from an interface holding two. These tests drive the policy (ResponsePools) and the +that has sent nothing (and is past half its grace) from an interface holding two. The fast pool holds 16 +responses by default (test_h1_regressions); the server tests here use two fast-pool slots. These tests drive the policy (ResponsePools) and the RequestServer over fake RNS objects and a FakeClock. """ import unittest @@ -30,15 +31,18 @@ def grace(self, bitrate, hops): return server.response_grace(bitrate, hops, self.config) def test_grace_grows_with_hops_and_shrinks_with_bitrate(self): - self.assertLess(self.grace(LORA_BITRATE, 1), self.grace(LORA_BITRATE, 2)) - self.assertLess(self.grace(LORA_BITRATE, 2), self.grace(LORA_BITRATE, 3)) - self.assertGreater(self.grace(LORA_BITRATE, 2), self.grace(2 * LORA_BITRATE, 2)) + unclamped = Config(deadline_slack=1000) + self.assertLess(server.response_grace(LORA_BITRATE, 1, unclamped), + server.response_grace(LORA_BITRATE, 2, unclamped)) + self.assertLess(server.response_grace(LORA_BITRATE, 2, unclamped), + server.response_grace(LORA_BITRATE, 3, unclamped)) + self.assertGreater(self.grace(LORA_BITRATE, 1), self.grace(2 * LORA_BITRATE, 1)) # A ~500-byte advertisement over 3 hops at 1 kbit/s: ~4 s per hop each way, so the first part request # arrives after ~24 s, inside the grace self.assertGreater(self.grace(LORA_BITRATE, 3), 24 + 2) - # Over TCP the hop count hardly matters: a few seconds + # Over TCP, on a path RNS raised the MTU of (no slow radio on it), the hop count hardly matters self.assertLess(self.grace(TCP_BITRATE, 1), 5) - self.assertLess(self.grace(TCP_BITRATE, 30), 5) + self.assertLess(server.response_grace(TCP_BITRATE, 30, self.config, fast=True), 6) def test_grace_is_clamped(self): self.assertEqual(self.grace(LORA_BITRATE, 10), self.config.deadline_slack) @@ -151,6 +155,7 @@ def test_newcomer_from_an_unrepresented_interface_takes_a_slot_from_a_crowded_on a, _ = self.take(b"a") self.clock.now += 1 b, _ = self.take(b"b") + self.clock.now += self.GRACE / 2 + 0.1 # past half their grace # Both within their grace and nothing sent, both from the hub: a newcomer from the hub is refused self.assertEqual(self.take(b"c"), (None, None)) response, displaced = self.take(b"d", other) @@ -179,7 +184,8 @@ def setUp(self): self.hub = FakeInterface("TCP hub", TCP_BITRATE) def harness(self, config=None) -> ServerHarness: - h = ServerHarness({self.cid: self.block, self.cid2: self.block2}, config) + """A server with two fast-pool slots (unless config says otherwise), so a few links fill the pool.""" + h = ServerHarness({self.cid: self.block, self.cid2: self.block2}, config or Config(fast_blocks_responses=2)) self.server = h.server return h @@ -269,7 +275,7 @@ def test_identity_cools_down_with_its_link(self): self.assertIsNot(h.blocks(self.tcp(h), [self.cid], identity=RNS.Identity()), False) def test_cooldown_zero_turns_it_off(self): - h = self.harness(Config(blocks_cooldown=0)) + h = self.harness(Config(blocks_cooldown=0, fast_blocks_responses=2)) a = self.tcp(h) self.take(h, a) self.take(h, self.tcp(h)) @@ -338,6 +344,7 @@ def test_newcomer_on_another_interface_is_served_while_one_interface_holds_both_ h.tick(1) self.take(h, b) self.assertIs(h.blocks(self.tcp(h), [self.cid]), False) # same interface: refused during the grace + h.tick(1) # a is past half its grace, b not yet other = self.tcp(h, interface=FakeInterface("TCP to a second hub", TCP_BITRATE)) self.assertIsNot(h.blocks(other, [self.cid]), False) self.assertTrue(held_a.cancelled) # the oldest response from the crowded interface From 90a2952693529ac5200794bf995c6389744e5dc9 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:46 +1030 Subject: [PATCH 080/107] Test the shared-instance, multi-hop and queued-radio graces, the fairness and slowest-response rules, the fast pool and the bounded upload_budget commitment --- tests/security/test_h1_regressions.py | 336 +++++++++++++++++++++ tests/security/test_m2_commitment_bound.py | 89 ++++++ tests/security/test_rns_compat_security.py | 68 ++++- 3 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 tests/security/test_h1_regressions.py create mode 100644 tests/security/test_m2_commitment_bound.py diff --git a/tests/security/test_h1_regressions.py b/tests/security/test_h1_regressions.py new file mode 100644 index 0000000..08e45c5 --- /dev/null +++ b/tests/security/test_h1_regressions.py @@ -0,0 +1,336 @@ +"""H1 follow-up: the progress-earned displacement protection hurt honest requesters in some setups, and a requester +taking a little of each response could renew its slots. + +- Attached to a shared instance (what the spec recommends), every incoming link arrives on the local interface to + it (RNS LocalClientInterface, 1 Gbit/s), so a LoRa neighbour got a 3 s grace and a 125 kB/s floor: it was + displaced and cooled down while downloading honestly. Its bitrate is now unknown. +- Hops beyond the first were priced at the node's own interface bitrate, so a TCP node serving a requester behind + a LoRa segment gave it a 3 s grace where the first part request takes ~15 s. They are now priced at + unmeasured_bitrate, or slow_bitrate on a path RNS raised the MTU of; and on the node's own slow radio the grace + also covers a window of parts queued for each other response there. +- The per-interface fairness rule evicted responses from a busy interface at once, even ones just advertised. It + now waits until a response is past half its grace, and evicts at most once per grace per interface. +- The rule that displaces the slowest moving response older than deadline_slack never cooled anyone down, so a + requester taking one part of each response could keep its slots young from a spare link. Now it doesn't fire + for a newcomer whose link or identity already holds a slot in the pool, and a response it displaces that is + far behind the rate it needs counts as making no progress. +- The fast pool holds fast_blocks_responses (16) responses, so holding it takes many links; blocks_cooldown is + 300 s. + +Driven over the fakes (tests/test_server.py, _server_helpers) with a FakeClock. +""" +import unittest + +import RNS +from RNS.Interfaces.LocalInterface import LocalClientInterface + +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE +from rnfs.network import server +from tests.security._server_helpers import LORA_BITRATE, TCP_BITRATE, FakeInterface, PathLink, ServerHarness +from tests.support import FakeClock +from tests.test_server import block_of + +LORA_RATE = 125 # bytes/s: what an honest 1 kbit/s LoRa neighbour downloads at + + +def config_with(**settings) -> Config: + """A default Config with settings set as attributes (so these tests also run, and fail as they should, on code + that predates a setting: there it is simply ignored).""" + config = Config() + for name, value in settings.items(): + setattr(config, name, value) + return config + + +def shared_instance_interface() -> LocalClientInterface: + """A real RNS LocalClientInterface as LocalClientInterface.connect() leaves it (no socket): the interface every + link of a process attached to a shared instance arrives on (phase4 shared_instance_path.log).""" + interface = LocalClientInterface.__new__(LocalClientInterface) + interface.name = "Local shared instance" + interface.socket_path = "\0rns/default" + interface.bitrate = 1_000_000_000 + interface.is_connected_to_shared_instance = True + return interface + + +class Download: + """Drive a response's FakeResource as an honest requester would: its first part request arrives first_part + seconds after the advertisement, then parts flow at rate bytes/s until it completes.""" + + def __init__(self, harness: ServerHarness, link, first_part: float, rate: float): + self.h = harness + self.resource = harness.advertise(link) + self.response = harness.response(link) + self.first_part = first_part + self.rate = rate + self.started = harness.clock.now + + def step(self): + """Update the Resource's progress for the clock's current time.""" + elapsed = self.h.clock.now - self.started + if self.resource.status == RNS.Resource.COMPLETE or elapsed < self.first_part: + return + self.resource.status = RNS.Resource.TRANSFERRING + self.resource.progress = min(1.0, (elapsed - self.first_part) * self.rate / self.response.size) + if self.resource.progress >= 1.0: + self.resource.complete() + + @property + def done(self) -> bool: + return self.resource.status == RNS.Resource.COMPLETE + + +class HonestAgainstNewcomersMixin: + """An honest download alongside newcomers on the same interface that ask every 2 s and never download.""" + + def run_alongside_newcomers(self, h: ServerHarness, download: Download, newcomer, cid): + """Run until download completes; assert its response is never cancelled and keeps its slot.""" + t = 0 + while not download.done: + self.assertLess(t, 3000, "the honest transfer never completed") + t += 1 + h.clock.now += 1 + download.step() + h.server.check_responses() + self.assertFalse(download.resource.cancelled, f"the honest response was cancelled at {t}s") + if t % 2 == 0: + link = newcomer() + if h.blocks(link, [cid]) is not False: + h.advertise(link) + if not download.done: + self.assertIs(h.response(download.resource.link), download.response, + f"the honest response lost its slot at {t}s") + return t + + +class SharedInstanceTest(HonestAgainstNewcomersMixin, unittest.TestCase): + """A shared-instance link's interface bitrate says nothing about its path: it is treated as unknown.""" + + def setUp(self): + (self.cid, self.block), (self.cid2, self.block2) = block_of(CHUNK_SIZE, 1), block_of(CHUNK_SIZE, 2) + self.local = shared_instance_interface() + + def test_shared_instance_link_gets_the_unknown_bitrate_treatment(self): + config = Config() + lora_neighbour = PathLink(b"a" * 16, self.local, 1, mtu=500) + interface, bitrate, hops = server.link_path(lora_neighbour) + # H1 regression: fails until a link on the interface to a shared instance has no (1 Gbit/s) bitrate + self.assertIsNone(bitrate, "the shared instance's local interface bitrate was taken as the path's") + self.assertIs(interface, self.local) + self.assertEqual(hops, 1) + self.assertEqual(server.progress_floor(bitrate, hops, config), config.unmeasured_bitrate / 8) + self.assertEqual(server.response_grace(bitrate, hops, config, server.fast_path(lora_neighbour)), + config.deadline_slack) + # Any interface RNS tells apart as the one to the shared instance, whatever its class + flagged = FakeInterface("to the shared instance", 1_000_000_000) + flagged.is_connected_to_shared_instance = True + self.assertIsNone(server.link_path(PathLink(b"b" * 16, flagged, 1))[1]) + self.assertEqual(server.link_path(PathLink(b"c" * 16, FakeInterface("TCP", TCP_BITRATE), 1))[1], + TCP_BITRATE) + + def test_honest_lora_neighbour_through_a_shared_instance_keeps_its_slot_and_never_cools_down(self): + """A LoRa neighbour (MTU 500, one hop) downloads at ~125 B/s through the shared instance; newcomers on the + same local interface ask every 2 s and never download.""" + h = ServerHarness({self.cid: self.block, self.cid2: self.block2}) + honest = h.connect(self.local, hops=1) + self.assertIsNot(h.blocks(honest, [self.cid]), False) + # First part request ~8 s after the advertisement: 4 s for the advertisement at 1 kbit/s, and back + download = Download(h, honest, first_part=8, rate=LORA_RATE) + self.assertEqual(download.response.pool, server.SLOW_POOL) + t = self.run_alongside_newcomers(h, download, lambda: h.connect(self.local, hops=1), self.cid2) + self.assertGreater(t, 500) # many minutes alongside the newcomers + # H1 regression: fails until the floor isn't 1/1000 of the local interface's 1 Gbit/s (125 kB/s) + self.assertFalse(h.server.cooldowns.active(honest.link_id), "the honest LoRa neighbour was cooled down") + + +class MultiHopGraceTest(HonestAgainstNewcomersMixin, unittest.TestCase): + """Hops beyond the first are priced at unmeasured_bitrate, unless RNS raised the path's MTU.""" + + def setUp(self): + (self.cid, self.block), (self.cid2, self.block2) = block_of(CHUNK_SIZE, 1), block_of(CHUNK_SIZE, 2) + self.tcp = FakeInterface("TCP to a transport node", TCP_BITRATE) + + def test_tcp_node_serving_a_three_hop_lora_requester(self): + """The requester is 3 hops away, behind a transport node with a LoRa segment (so the link's MTU stays 500): + its first part request arrives ~15 s after the advertisement. Newcomers ask every 2 s from t=2 s.""" + h = ServerHarness({self.cid: self.block, self.cid2: self.block2}) + honest = h.connect(self.tcp, hops=3, mtu=500, mdu=431) + self.assertIsNot(h.blocks(honest, [self.cid]), False) + download = Download(h, honest, first_part=15, rate=LORA_RATE) + # H1 regression: fails until hops past the first aren't priced at the TCP interface's 10 Mbit/s + self.assertGreaterEqual(download.response.grace, 15 + 2, "the grace doesn't cover the LoRa segment") + self.run_alongside_newcomers(h, download, lambda: h.connect(self.tcp, hops=1, mtu=500, mdu=431), self.cid2) + self.assertFalse(h.server.cooldowns.active(honest.link_id)) + # On a path RNS raised the MTU of, there is no slow radio: the grace stays short at any hop count + self.assertLess(server.response_grace(TCP_BITRATE, 3, h.config, fast=True), 4) + fast = h.connect(self.tcp, hops=3, mtu=1500, mdu=1400) + self.assertIsNot(h.blocks(fast, [self.cid]), False) + self.assertLess(h.response(fast).grace, 4) + + def test_grace_covers_advertisements_queued_on_the_nodes_own_slow_radio(self): + """On the node's own 1 kbit/s radio, a new advertisement may wait behind a window of another response's + parts (with the radio's flow control off, RNS can't tell when it actually leaves).""" + lora = FakeInterface("RNode", LORA_BITRATE) + h = ServerHarness({self.cid: self.block, self.cid2: self.block2}, Config(deadline_slack=60)) + first = h.connect(lora, hops=1) + self.assertIsNot(h.blocks(first, [self.cid]), False) + h.advertise(first) + alone = h.response(first).grace + self.assertAlmostEqual(alone, server.GRACE_BASE + 2 * 4) + second = h.connect(lora, hops=1) + self.assertIsNot(h.blocks(second, [self.cid2]), False) + # H1 regression: fails until the grace also covers what may be queued ahead on the node's own radio + self.assertGreaterEqual(h.response(second).grace, alone + 10 * 500 * 8 / LORA_BITRATE) + # Not on a fast interface, whose queue drains in no time + tcp = FakeInterface("TCP", TCP_BITRATE) + a, b = h.connect(tcp, mtu=1500, mdu=1400), h.connect(tcp, mtu=1500, mdu=1400) + self.assertIsNot(h.blocks(a, [self.cid]), False) + self.assertIsNot(h.blocks(b, [self.cid]), False) + self.assertEqual(h.response(a).grace, h.response(b).grace) + + +class InterfaceFairnessTest(unittest.TestCase): + """The per-interface fairness rule (rule 2) spares young responses and fires at most once per grace.""" + + GRACE = 10.0 + FLOOR = 100.0 + + def setUp(self): + self.clock = FakeClock() + self.lora = FakeInterface("LoRa", LORA_BITRATE) + self.tcp = FakeInterface("TCP", TCP_BITRATE) + + def take(self, pools, name: bytes, interface): + return pools.reserve(server.FAST_POOL, name, name, grace=self.GRACE, floor=self.FLOOR, interface=interface) + + def test_responses_early_in_their_grace_are_not_evicted(self): + """A requester on another interface (e.g. TCP, signalling MTU 500 to land in the slow pool) can't evict + responses the node advertised over LoRa a moment ago.""" + pools = server.ResponsePools(4, 30, self.clock) + a, _ = self.take(pools, b"a", self.lora) + b, _ = self.take(pools, b"b", self.lora) + self.clock.now += self.GRACE / 2 - 0.1 + # H1 regression: fails until rule 2 waits for the first half of a response's grace + self.assertEqual(self.take(pools, b"c", self.tcp), (None, None), "a response just advertised was evicted") + self.clock.now += 0.2 + response, displaced = self.take(pools, b"c", self.tcp) + self.assertIsNotNone(response) + self.assertIs(displaced, a) + self.assertFalse(displaced.idle) # still within its grace: no cooldown + + def test_at_most_one_eviction_per_grace_per_interface(self): + pools = server.ResponsePools(6, 30, self.clock) # 3 slots per pool + held = [self.take(pools, bytes([i]) * 4, self.lora)[0] for i in range(3)] + self.clock.now += self.GRACE / 2 + 0.1 + response, displaced = self.take(pools, b"tcp1", self.tcp) + self.assertIs(displaced, held[0]) + # LoRa still holds two, but it has just lost one this way; another interface's newcomer waits + other = FakeInterface("TCP 2", TCP_BITRATE) + # H1 regression: fails until rule 2 evicts at most once per grace per interface + self.assertEqual(self.take(pools, b"tcp2", other), (None, None), "rule 2 evicted twice within one grace") + self.assertEqual(set(pools.responses()), {held[1], held[2], response}) + + +class SelfRenewalTest(unittest.TestCase): + """The slowest-response rule (rule 3) can't be used to keep one's own slots young for free.""" + + def setUp(self): + self.blocks = dict(block_of(CHUNK_SIZE, i) for i in range(4)) + self.cids = list(self.blocks) + self.hub = FakeInterface("TCP hub", TCP_BITRATE) + + def harness(self) -> ServerHarness: + return ServerHarness(self.blocks, config_with(fast_blocks_responses=2)) + + def link(self, h: ServerHarness, hops=2): + """A link over the TCP hub on a path RNS raised the MTU of, reporting hops hops.""" + return h.connect(self.hub, hops=hops, mtu=1500, mdu=1400) + + def take_one_part(self, h: ServerHarness, link, cid, identity=None): + """Ask for cid on link, accept the advertisement, and take one part (1400 bytes) only.""" + self.assertIsNot(h.blocks(link, [cid], identity=identity), False) + resource = h.advertise(link) + resource.status = RNS.Resource.TRANSFERRING + resource.progress = 1400 / h.response(link).size + h.tick(0.25) + return resource + + def test_slow_requester_renewing_its_slots_from_a_spare_link_cools_down(self): + h = self.harness() + a, b, spare = self.link(h), self.link(h), self.link(h) + held = [self.take_one_part(h, a, self.cids[0]), self.take_one_part(h, b, self.cids[1])] + # One part of 64 KiB, then nothing: above the 12.5 B/s floor behind two hops for ~110 s, so protected + h.tick(h.config.deadline_slack + 1) + self.assertTrue(all(h.response(link) is not None for link in (a, b))) + self.assertIsNot(h.blocks(spare, [self.cids[2]]), False) # rule 3: displaces the slowest + displaced = a if held[0].cancelled else b + self.assertTrue(held[0].cancelled or held[1].cancelled) + # H1 residual: fails until a response displaced far behind the rate it needs counts as making no progress + self.assertTrue(h.server.cooldowns.active(displaced.link_id), "the renewing requester wasn't cooled down") + self.assertIs(h.blocks(displaced, [self.cids[3]]), False) + + def test_honest_download_displaced_by_the_slowest_rule_does_not_cool_down(self): + """The counterpart: a response keeping to its schedule, displaced only for being the slowest, doesn't.""" + h = self.harness() + a, b = self.link(h), self.link(h) + ra, rb = self.take_one_part(h, a, self.cids[0]), self.take_one_part(h, b, self.cids[1]) + for _ in range(int(h.config.deadline_slack) + 2): + for resource, rate in ((ra, 1500), (rb, 2000)): # ~1.5-2 kB/s: a slow but honest fast-path download + resource.progress = min(0.99, resource.progress + rate / h.response(resource.link).size) + h.tick(1) + self.assertIsNot(h.blocks(self.link(h), [self.cids[2]]), False) + self.assertTrue(ra.cancelled) + self.assertFalse(h.server.cooldowns.active(a.link_id)) + + def test_requester_already_holding_a_slot_cannot_displace_by_the_slowest_rule(self): + h = self.harness() + identity = RNS.Identity() + mine, other = self.link(h), self.link(h) + rm = self.take_one_part(h, mine, self.cids[0], identity=identity) + ro = self.take_one_part(h, other, self.cids[1]) + for _ in range(int(h.config.deadline_slack) + 2): + for resource in (rm, ro): # both moving well: neither is far behind + resource.progress = min(0.99, resource.progress + 4000 / h.response(resource.link).size) + h.tick(1) + # H1 residual: fails until rule 3 refuses a newcomer whose identity already holds a slot in the pool + self.assertIs(h.blocks(self.link(h), [self.cids[2]], identity=identity), False, + "a requester displaced a response to renew its own slots") + self.assertFalse(rm.cancelled or ro.cancelled) + self.assertIsNot(h.blocks(self.link(h), [self.cids[2]], identity=RNS.Identity()), False) + + +class FastPoolCapacityTest(unittest.TestCase): + """Many more fast-link slots by default (fast_blocks_responses), the slow pool unchanged, a longer cooldown.""" + + def test_defaults(self): + config = Config() + # H1: fails until the fast pool holds fast_blocks_responses (16) and blocks_cooldown is 300 s + self.assertEqual(getattr(config, "fast_blocks_responses", None), 16) + self.assertEqual(config.blocks_cooldown, 300) + h = ServerHarness({}) + self.assertEqual(h.server.pools.capacity, {server.SLOW_POOL: 2, server.FAST_POOL: 16}) + + def test_sixteen_fast_links_are_served_at_once_and_the_slow_pool_is_unchanged(self): + cid, block = block_of(CHUNK_SIZE, 1) + h = ServerHarness({cid: block}) + tcp, lora = FakeInterface("TCP", TCP_BITRATE), FakeInterface("LoRa", LORA_BITRATE) + served = [h.blocks(h.connect(tcp, mtu=1500, mdu=1400), [cid]) is not False for _ in range(17)] + self.assertEqual(served, [True] * 16 + [False]) + served = [h.blocks(h.connect(lora), [cid]) is not False for _ in range(3)] + self.assertEqual(served, [True, True, False]) + + def test_setting_is_validated_and_never_shrinks_the_old_split(self): + for bad in (0, -1, True, "9", 1.5, None): + with self.assertRaises(ValueError, msg=repr(bad)): + config_with(fast_blocks_responses=bad).validate() + config_with(fast_blocks_responses=1).validate() + capacity = ServerHarness({}, config_with(fast_blocks_responses=1)).server.pools.capacity + self.assertEqual(capacity, {server.SLOW_POOL: 2, server.FAST_POOL: 2}) + config = config_with(fast_blocks_responses=16) + config.max_blocks_responses = 60 + self.assertEqual(ServerHarness({}, config).server.pools.capacity, {server.SLOW_POOL: 30, server.FAST_POOL: 30}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_m2_commitment_bound.py b/tests/security/test_m2_commitment_bound.py new file mode 100644 index 0000000..d0aa28e --- /dev/null +++ b/tests/security/test_m2_commitment_bound.py @@ -0,0 +1,89 @@ +"""M2 follow-up: a response in flight counted its whole unsent rest as committed upload_budget once it had sent +anything, so a few responses crawling along (one part, then next to nothing) could hold admission for every other +requester while spending nothing. + +Now, after its grace, a response commits at most what it sends at its progress rate before its deadline, and +nothing once it is below its progress floor. Driven with the fakes and a FakeClock. +""" +import unittest + +import RNS + +from rnfs import mesh +from rnfs.config import Config +from rnfs.constants import CHUNK_SIZE, KIB, MAX_RESPONSE_SIZE +from tests.security._server_helpers import TCP_BITRATE, FakeInterface, ServerHarness +from tests.test_server import block_of + +MDU = 1400 +BUDGET = 500_000 # a LoRa-like hourly budget, as in test_m2_upload_budget + + +class CommitmentBoundTest(unittest.TestCase): + + def setUp(self): + self.big = [block_of(CHUNK_SIZE, i) for i in range(6)] + self.cids = [cid for cid, _ in self.big] + self.hub = FakeInterface("TCP hub", TCP_BITRATE) + # Plenty of free slots, so a newcomer displaces nothing: only the budget decides + self.h = ServerHarness(dict(self.big), Config(upload_budget=BUDGET, max_blocks_responses=40)) + + def request(self, count=3): + """/blocks for count blocks on a new directly connected TCP link with a rate estimate (1 Mbit/s), accepted + and advertised, its Resource transferring: (response, resource).""" + link = self.h.connect(self.hub, mtu=1500, mdu=MDU) + self.h.server.rates.record(link.link_id, 64 * KIB, 1_000_000) + self.assertIsNot(self.h.blocks(link, self.cids[:count], max_bytes=MAX_RESPONSE_SIZE), False) + resource = self.h.advertise(link) + resource.status = RNS.Resource.TRANSFERRING + return self.h.response(link), resource + + def crawl(self, *pairs): + """Each response sends one part, then nothing, until well past its grace (below its floor).""" + for response, resource in pairs: + resource.progress = MDU / response.size + self.h.tick(0.25) + self.h.tick(max(response.grace for response, _ in pairs) + 2) + for response, _ in pairs: + self.assertFalse(response.finished) + self.assertFalse(response.moving(self.h.clock.now)) + + def test_crawling_response_below_its_floor_commits_nothing(self): + response, resource = self.request() + self.crawl((response, resource)) + # M2: fails until a response below its floor stops counting its unsent rest as committed + self.assertEqual(self.h.server._committed(), 0, "a crawling response holds admission") + + def test_moving_response_commits_at_most_what_it_sends_by_its_deadline(self): + response, resource = self.request() + rate = 2000 # bytes/s: above the 1.25 kB/s floor, far below what it needs + for _ in range(int(response.grace) + 6): + resource.progress = min(1.0, resource.progress + rate / response.size) + self.h.tick(1) + now = self.h.clock.now + self.assertTrue(response.moving(now)) + rest = response.cost - response.charged + bound = response.progress_rate(now) * (response.deadline - now) + self.assertLess(bound, rest / 2) + # M2: fails until the commitment is bounded by what the response's rate sends before its deadline + self.assertLessEqual(self.h.server._committed(), bound + 1) + + def test_crawling_responses_do_not_cut_an_honest_response(self): + held = [self.request() for _ in range(2)] + self.crawl(*held) + honest = self.h.connect(self.hub, mtu=1500, mdu=MDU) + self.h.server.rates.record(honest.link_id, 64 * KIB, 1_000_000) + answer = self.h.blocks(honest, self.cids[3:6], max_bytes=MAX_RESPONSE_SIZE) + self.assertIsNot(answer, False) + # M2: fails until crawling responses stop committing (before: cut to one block of three) + self.assertEqual(len([entry for entry in answer if entry is not None]), 3) + self.assertEqual(self.h.server.traffic.snapshot()["refused"].get(mesh.REFUSED_BUDGET, 0), 0) + + def test_responses_within_their_grace_still_commit_their_rest(self): + """The counterpart: responses admitted together can't overrun the bucket.""" + response, _ = self.request() + self.assertEqual(self.h.server._committed(), response.cost - response.charged) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_rns_compat_security.py b/tests/security/test_rns_compat_security.py index 5e0d94a..99b7eb9 100644 --- a/tests/security/test_rns_compat_security.py +++ b/tests/security/test_rns_compat_security.py @@ -4,7 +4,10 @@ server.py's displacement protection measures a per-response grace from the receiving interface's bitrate and the requester's hop count, read through server.link_path(). Those are RNS object attributes that the version number doesn't promise to keep, so, like tests/test_rns_compat.py (another session's file, not edited here), -this checks each one and fails naming what changed rather than letting the grace silently fall back. +this checks each one and fails naming what changed rather than letting the grace silently fall back. The +follow-up also relies on how RNS marks the local interface to a shared instance (whose bitrate says nothing about +a link's path), on RNS raising a link's MTU only across interfaces that configure theirs, and on how a Resource's +advertisement and parts reach a slow radio. guards.limit_half_open replaces a destination's incoming_link_request and reads Destination.links and each link's status, attached_interface and request_time, and closes links with Link.teardown(); those are checked @@ -95,6 +98,69 @@ class _Link: self.assertEqual(server.progress_floor(None, None, config), config.unmeasured_bitrate / 8) +class RnsGracePathInternalsTest(unittest.TestCase): + """What the H1 follow-up (shared instances, multi-hop paths, the node's own slow radio) relies on: how RNS + marks the interface to a shared instance, when it raises a link's MTU, and how it queues a Resource's parts.""" + + def test_interface_to_a_shared_instance_is_told_by_its_attribute(self): + # server.via_shared_instance tells the local interface to a shared instance the way RNS does + self.assertIn("hasattr(interface,'is_connected_to_shared_instance')", + _normal_source(RNS.Transport.interface_to_shared_instance)) + from RNS.Interfaces import LocalInterface + self.assertIn("self.is_connected_to_shared_instance=True", + _normal_source(LocalInterface.LocalClientInterface.connect)) + self.assertEqual({LocalInterface.LocalClientInterface.__name__, LocalInterface.LocalServerInterface.__name__}, + set(server.rates.LOCAL_INTERFACE_TYPES)) + # Packets from the shared instance have a hop taken off, so a direct neighbour's link has expected_hops 1 + self.assertIn("elifTransport.interface_to_shared_instance(packet.receiving_interface):packet.hops-=1", + _normal_source(RNS.Transport._inbound)) + + def test_link_path_gives_a_shared_instance_link_no_bitrate(self): + from RNS.Interfaces.LocalInterface import LocalClientInterface + interface = LocalClientInterface.__new__(LocalClientInterface) + interface.bitrate = 1_000_000_000 + interface.is_connected_to_shared_instance = True + + class _Link: + attached_interface = interface + expected_hops = 1 + + self.assertEqual(server.link_path(_Link()), (interface, None, 1)) + + def test_link_mtu_is_raised_only_across_interfaces_that_configure_theirs(self): + # server.fast_path: a link's MTU above 500 means every interface on the path autoconfigures its MTU + # (at 62.5 kbit/s and up) or has a fixed one; transport nodes drop the MTU signalling otherwise + self.assertIn("elifnotoutbound_interface.AUTOCONFIGURE_MTUandnotoutbound_interface.FIXED_MTU:", + _normal_source(RNS.Transport._inbound)) + self.assertIn("ifpacket.receiving_interface.AUTOCONFIGURE_MTUorpacket.receiving_interface.FIXED_MTU:", + _normal_source(RNS.Transport._inbound)) + from RNS.Interfaces.KISSInterface import KISSInterface + from RNS.Interfaces.RNodeInterface import RNodeInterface + for radio in (RNodeInterface, KISSInterface): + self.assertFalse(radio.AUTOCONFIGURE_MTU or radio.FIXED_MTU, radio.__name__) + interface = Interface.__new__(Interface) + interface.AUTOCONFIGURE_MTU = True + for bitrate, mtu in ((62_499, None), (62_500, 1024)): + interface.bitrate = bitrate + interface.optimise_mtu() + self.assertEqual(interface.HW_MTU, mtu, bitrate) + self.assertTrue(callable(RNS.Link.get_mtu)) + + def test_parts_queue_on_a_slow_radio_without_rns_knowing_when_they_leave(self): + # server.QUEUED_WINDOW_BITS: one window of parts on a slow link (WINDOW_MAX_SLOW) of one packet each + self.assertEqual(RNS.Resource.WINDOW_MAX_SLOW, 10) + self.assertEqual(server.QUEUED_WINDOW_BITS, RNS.Resource.WINDOW_MAX_SLOW * RNS.Reticulum.MTU * 8) + # RNS registers an outgoing Resource (where rnfs restarts its grace) right after handing its + # advertisement to the interface; an RNode's flow control is off unless configured, so the packet goes + # to the device at once and RNS can't tell when it is on the air + source = _normal_source(RNS.Resource._Resource__advertise_job) + self.assertLess(source.index("self.advertisement_packet.send()"), + source.index("self.link.register_outgoing_resource(self)")) + from RNS.Interfaces.RNodeInterface import RNodeInterface + self.assertIn("flow_control=c.as_bool('flow_control')if'flow_control'incelseFalse", + _normal_source(RNodeInterface.__init__)) + + class RnsHalfOpenLinkInternalsTest(unittest.TestCase): """Each RNS attribute and method guards.limit_half_open (M1) hooks or reads is present and behaves as rnfs expects.""" From e6fada8213793c517f53ceb684d08406d832d2cc Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:22:46 +1030 Subject: [PATCH 081/107] Document the /blocks grace for shared instances and multi-hop paths, the fast pool, the bounded commitment and the remaining slot-holding limits --- CHANGELOG.md | 35 ++++++++----- README.md | 5 +- design.md | 32 +++++++----- specification.md | 125 ++++++++++++++++++++++++++++++----------------- 4 files changed, 129 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f39555..1bd8808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,13 +91,24 @@ Gateway privacy: Serving `/blocks` under load: - Displacement protection is now earned by progress, so one peer can no longer hold every `/blocks` response slot almost for free. A response is protected from displacement only while it is within a short grace the - node measures from the receiving interface's bitrate and the request's hop count (never from the - requester-controlled `link.rtt`), or while it is moving data at or above a throughput floor. A response that - transfers nothing is cancelled and its upload-budget charge refunded once its grace passes, and any response - with no progress for `stall_timeout` is cancelled too. + node measures from the path its request came over (never from the requester-controlled `link.rtt`), or while + it is moving data at or above a throughput floor. The grace prices the first hop at the receiving + interface's bitrate and further hops as slow ones unless RNS raised the link's MTU (so a requester behind a + LoRa segment gets enough time however fast the node's own interface is), and on the node's own slow radio it + also covers parts queued there for other responses. Attached to a shared instance, where every link arrives + on the local interface to it, the bitrate is taken as unknown, so LoRa neighbours aren't held to a 1 Gbit/s + interface's grace and floor. A response that transfers nothing is cancelled and its upload-budget charge + refunded once its grace passes, and any response with no progress for `stall_timeout` is cancelled too. - A link (and its identity, if it identified) whose response was displaced or cancelled for making no progress - is refused new `/blocks` for `blocks_cooldown` (new setting, default 60 s). A newcomer on an interface that - holds no slot in a full pool may displace a zero-progress response from an interface holding two or more. + is refused new `/blocks` for `blocks_cooldown` (new setting, default 300 s). So is one whose response was + displaced as the slowest while far behind the rate it needs to be done in time, and a requester whose link + or identity already holds a slot can't displace that way, so taking a little of each response no longer + keeps one's slots young. A newcomer on an interface that holds no slot in a full pool may displace a + zero-progress response from an interface holding two or more, once that response is past half its grace, + at most once per grace per interface. +- Links that aren't slow get 16 `/blocks` slots (new setting `fast_blocks_responses`; it used to be 2, the + other half of `max_blocks_responses`), so holding them takes many links each actually downloading. Slow + links keep their 2. At most about 40 MiB is held for responses in flight on fast links. - A `/blocks` request that repeats a CID is refused as malformed, so a node can't be made to read and serve the same block several times in one response. - On a link with no rate estimate a `/blocks` response holds at most `CHUNK_SIZE` of blocks after the first @@ -105,14 +116,16 @@ Serving `/blocks` under load: requester sent, so one packet could ask for a ~1 MiB response with a deadline of many hours. - `upload_budget` is charged as a response is sent, from its Resource's progress, instead of its full size up front with a refund at the end. A response is charged about one packet when it starts, and the rest of it is - set aside only while it is within its grace or moving, so responses a requester never downloads (each - unidentified link counting as its own requester) no longer hold the budget. A completed response costs its + set aside only while it is within its grace, then only what it sends at its rate before its deadline, and + nothing below its floor, so responses a requester never downloads or crawls through (each unidentified link + counting as its own requester) no longer hold the budget. A completed response costs its size and a cancelled one what it sent, as before. The half-per-requester rule now caps what one requester's responses in flight may cost. - Link requests that never complete their handshake are counted in a bucket of their own, of `max_incoming_links`, on `rnfs.dht` and the LXMF delivery destination. When it is full the oldest one on the busiest interface is closed. RNS keeps such links (each with a thread) for at least 366 s and rnfs only saw - links once established, so a flood of link requests could exhaust the node's memory or threads. + links once established, so a flood of link requests could exhaust the node's memory or threads. A flood on + one interface still shortens the time an honest handshake over that interface has to complete. - A link (and its identity) that rejects a `/blocks` response's advertisement, so that none of it is sent, cools down for `blocks_cooldown` as after a response that made no progress. A rejected Resource frees its slot at once, so a requester could have the node build a response, reject it and ask again in a loop (a @@ -123,8 +136,8 @@ Serving `/blocks` under load: already decoded a request and started a thread for it by then, so this only removes rnfs's share of a flood's cost (about 40% of the node's CPU per request, measured); honest requesters, which wait for their answers, stay far below it. -- Known limit: many unidentified links that each hold a slot for one grace, then sit out their cooldown, can - still deny the fast pool over a shared interface (specification section 10). +- Known limit: over a shared interface, many unidentified links that download slowly but on schedule, or a + steady stream of fresh links, can still keep the fast pool busy (specification section 10). Fetching from other nodes: - On `rnfs.dht` links a response's msgpack headers are checked before RNS decodes it, and one that can't be diff --git a/README.md b/README.md index da32d29..52a0af6 100644 --- a/README.md +++ b/README.md @@ -595,8 +595,9 @@ lxmf_auto_fetch_from = [""] | `response_target` | `60` | Transfer time each block response aims for | | `deadline_slack` | `30` | Added to response deadlines | | `stall_timeout` | `120` | A response with no progress for this long is abandoned | -| `blocks_cooldown` | `60` | A link whose response made no progress is refused `/blocks` this long | -| `max_blocks_responses` | `4` | Uploads in progress at once (half reserved for slow links; ≥ 2) | +| `blocks_cooldown` | `300` | A link whose response made no progress is refused `/blocks` this long | +| `max_blocks_responses` | `4` | Uploads in progress at once on slow links: half of this (≥ 2) | +| `fast_blocks_responses` | `16` | Uploads in progress at once on other links (≥ 1; at least the other half of `max_blocks_responses`). Each holds up to ~2.5 MiB of memory while in flight | | `max_upload_rate` | none; half the measured rate on slow links | Upload cap per link (bits/s) | | `max_jobs` | 2; 1 on slow nodes | Transfers (`get`, `pin add`, `export`) running at once; set 1 on a slow radio | | `max_finished_jobs` | `100` | Finished jobs remembered by `rnfs jobs` (at least 1). A finished job is kept for at least 60 s whatever this says, so a command waiting for it always sees the result | diff --git a/design.md b/design.md index a961cf4..88de0f3 100644 --- a/design.md +++ b/design.md @@ -331,15 +331,24 @@ injected clocks); and `Traffic`, thread-safe counters per peer (LRU, at most 100 and in total, never persisted. - `Fetcher.path_info` (default: nothing known) is set by the node to `RnsPaths.info`; `Fetch._rank` orders both providers and open-link probes, and each provider caches its `PathInfo`. -- `ResponsePools` protects a response from displacement only while it is within a grace it measures from the - link's interface bitrate and hop count (`link_path`, `response_grace`, never from `link.rtt`), or moving at - or above a floor (`progress_floor`). A full pool displaces the least-progressing unprotected response (else - an interface-fairness pick: a zero-progress response from an interface holding two or more slots, for a - newcomer whose interface holds none; else the old slowest-past-`deadline_slack` rule), never one on the - newcomer's own link. `check_responses` cancels and refunds a response that has sent nothing once its grace - plus `deadline_slack` has passed, or that makes no progress for `stall_timeout`. `Cooldowns` then refuses - `/blocks` from a link (and its identity, if identified) whose response was displaced or cancelled for making - no progress, for `blocks_cooldown`, cheaply and before any slot is taken. `_concluded` starts the same +- `ResponsePools` has `max_blocks_responses // 2` slow-link slots and `fast_blocks_responses` (or the rest, if + more) for others. It protects a response from displacement only while it is within a grace it measures from + the link's path (`link_path`, `fast_path`, `response_grace`, never from `link.rtt`), or moving at or above a + floor (`progress_floor`). `link_path` treats the interface to a shared instance (`via_shared_instance`: RNS's + `is_connected_to_shared_instance` attribute, else the `LocalClientInterface` class name) as of unknown + bitrate. `response_grace` prices the first hop at the interface's bitrate and the others at + `unmeasured_bitrate`, or `slow_bitrate` when `fast_path` (the link's MTU is above 500, which RNS allows only + across interfaces that configure their MTU), and adds `RequestServer._queued_bits` on the node's own slow + interface (a window of parts per response in flight there). A full pool displaces the least-progressing + unprotected response; else (`_fairness_pick`), for a newcomer whose interface holds no slot, the oldest + zero-progress response past half its grace from an interface holding two or more, at most once per grace + per interface (`fairness_at`); else, for a newcomer whose link and identity hold no slot, the slowest + response older than `deadline_slack` and past its grace, marked idle if `Response.far_behind` (below a + quarter of `needed_rate`, measured against `Response.due`). Never one on the newcomer's own link. + `check_responses` cancels and refunds a response that has sent nothing once its grace plus `deadline_slack` + has passed, or that makes no progress for `stall_timeout`. `Cooldowns` then refuses `/blocks` from a link + (and its identity, if identified) whose response was displaced or cancelled idle, for `blocks_cooldown`, + cheaply and before any slot is taken. `_concluded` starts the same cooldown for a response whose Resource the requester rejected (`REJECTED`) with nothing sent (`_sent_any`: `Response.sent`, else the Resource's `get_progress()`). - `IncomingLinks.take_token` keeps a per-link token bucket (`REQUEST_BURST`, `REQUEST_RATE`) and a smaller @@ -349,8 +358,9 @@ and in total, never persisted. - `RequestServer.airtime` checks interface rules, quiet hours and an empty budget before taking any slot, caps the block budget, and charges the bucket as responses are sent. Each `Response` records its `cost` (packed size), what has been `charged` so far and its requester (identity hash, else link ID). `_admit` - (one admission at a time) allows a response if the bucket holds its cost beyond `_committed()` (the - uncharged rest of other responses within their grace or that have sent something), and charges a + (one admission at a time) allows a response if the bucket holds its cost beyond `_committed()` (the sum of + other responses' `Response.commitment`: the uncharged rest within the grace; after it, what the progress + rate sends before the deadline, or nothing below the floor), and charges a reservation of at most the link's MDU; single-packet and untracked responses are charged in full. `check_responses` charges progress as it reads it (`_charge_progress`), and `_settle` (from `_finish`) makes the charge final: the cost if completed or progress is unreadable, else progress × size, refunding diff --git a/specification.md b/specification.md index 72d1962..3f47b4a 100644 --- a/specification.md +++ b/specification.md @@ -140,33 +140,52 @@ pass a `max_response_size` just above their largest honest answer. stays far below this over a real network (over loopback a 10,000-key `/provide` runs at about 900 requests a second). Measured over real RNS, the limit cuts the node's CPU per flood request by about 40% (the rest is RNS's own work), so the flood rate one link needs to saturate the node about doubles. -- At most `max_blocks_responses` `/blocks` responses are in progress in total, split between two pools: - responses on slow links (`max_blocks_responses` // 2 slots) and on other links (the rest), so each pool - has at least one slot. A response stays in the pool it started in, even if its link's class changes. Each - pool competes only with itself, so neither kind of client can crowd out the other. +- `/blocks` responses in progress are split between two pools: responses on slow links + (`max_blocks_responses` // 2 slots) and on other links (`fast_blocks_responses` slots, or the rest of + `max_blocks_responses` if that is more), so each pool has at least one slot. A response stays in the pool it + started in, even if its link's class changes. Each pool competes only with itself, so neither kind of client + can crowd out the other. A response in flight holds its encoded size about 2.4 times over in RNS (measured + with RNS 1.5.6: the encrypted data and its parts), and at most `CHUNK_SIZE` plus one block on a link with no + rate estimate, so at the defaults the fast pool holds at most about 40 MiB (16 responses of 1 MiB on links + with a rate estimate; about 7 MiB with none) and the slow pool a few MiB. - A response's **deadline** is 3 × its size at the link's rate estimate (at `unmeasured_bitrate` if it has none), plus `deadline_slack`. A response not finished by then is cancelled. - **Displacement protection is earned by progress**, so a response that holds a slot without transferring can't keep honest requests out. A response is protected from displacement only while it is within a per-response **grace** the node measures itself, or while it is moving data at or above a **throughput - floor**. The grace is `base + 2 × hops × advertisement_bits / bitrate`, clamped to - `[grace_min, deadline_slack]`, where `bitrate` is the receiving interface's configured bitrate - (`Link.attached_interface`'s `bitrate`, `unmeasured_bitrate` if unknown) and `hops` is the request's hop - count (`Link.expected_hops`, clamped, since a requester can inflate it). It is never derived from - `link.rtt`, which the requester controls. The floor is `unmeasured_bitrate / 8` bytes/s, or a small - fraction of the interface's bitrate for a directly connected requester. + floor**. The grace is `base + 2 × (time for the advertisement to cross the path) + queue`, clamped to + `[grace_min, deadline_slack]`. The advertisement (one packet, `advertisement_bits`) crosses the first hop at + the receiving interface's configured bitrate (`Link.attached_interface`'s `bitrate`) and each further hop + of the request's hop count (`Link.expected_hops`, clamped, since a requester can inflate it) at + `unmeasured_bitrate`, or at `slow_bitrate` if RNS raised the link's MTU above 500, which it does only + across interfaces that configure their MTU (none of them slow radios); a hop behind a transport node may + be a LoRa segment however fast the node's own interface is. An unknown bitrate is priced like a further + hop. Attached to a shared instance, every link arrives on the local interface to it (RNS + `LocalClientInterface`, recognised as RNS does by its `is_connected_to_shared_instance` attribute), whose + bitrate says nothing about the path, so the bitrate is unknown there. On the node's own slow interface + (below `slow_bitrate`), `queue` is one window of parts (10 packets of 500 bytes) at its bitrate for each + response in flight on it: the advertisement may wait behind them, and RNS can't tell when it actually + leaves the radio. The grace is never derived from `link.rtt`, which the requester controls. The floor is + `unmeasured_bitrate / 8` bytes/s, or a small fraction of the interface's bitrate (if known) for a directly + connected requester. - When a pool is full, a new `/blocks` request displaces one of the pool's responses, never one on its own link: the response making least progress among those that are unprotected (past their grace and below the floor); else, if the newcomer's receiving interface holds no slot in the pool, the oldest response with - nothing sent from an interface that holds two or more; else the response with the lowest throughput, if it - is older than `deadline_slack` and past its grace. Otherwise the request is refused. + nothing sent from an interface that holds two or more, once it is past half its grace and at most one + within a grace from each interface; else, if neither the newcomer's link nor its identity holds a slot in + the pool, the response with the lowest throughput, if it is older than `deadline_slack` and past its + grace. Otherwise the request is refused. - A response that makes **no progress** is cancelled and its budget charge refunded: one with nothing sent, once its grace plus `deadline_slack` has passed; any response with no progress for `stall_timeout`. - **Cooldown.** A link (and its identity, if it identified) whose response was displaced or cancelled for making no progress, or whose response Resource it rejected before any of it was sent (no honest requester does: otherwise one could have the node build a response, reject it and ask again in a loop), is refused - new `/blocks` for `blocks_cooldown`. The refusal is answered at once, before - any slot is reserved or block read. + new `/blocks` for `blocks_cooldown`. A response displaced as the slowest counts as making no progress if + it was moving at less than a quarter of the rate it needs to be done when due: by its deadline, or, on a + link with no rate estimate whose MTU RNS raised, by the deadline it would have at `slow_bitrate`. So a + requester taking a little of each response can't keep its slots young by displacing its own responses + from a spare link; an honest one keeping to its schedule is displaced without a cooldown. The refusal is + answered at once, before any slot is reserved or block read. - A handler gets no direct handle on the response Resource RNS sends for it. Responses are tracked through the link's outgoing Resources (`Link.outgoing_resources`, matched by `request_id`) and cancelled with `Resource.cancel()` (which acts only while the Resource is not yet complete). These are RNS internals that @@ -179,10 +198,13 @@ pass a `max_response_size` just above their largest honest answer. counted above. So, on `rnfs.dht` and on the LXMF delivery destination, links still waiting (RNS `PENDING` or `HANDSHAKE`) are counted in a bucket of their own, also of `max_incoming_links`. When a link request arrives with the bucket full, the oldest waiting link on the interface holding the most of them (the - request's own interface on a tie) is closed, rather than the request refused: an honest handshake completes - within about one round trip, so a flood can't keep honest initiators out by keeping the bucket full, and a - flood arriving on one interface closes its own links first. Each request still costs the node the key - exchange and a proof; RNS's own long responder timeout is the root cause. + request's own interface on a tie) is closed, rather than the request refused. So a flood can't fill the + bucket and keep everyone out, and a flood arriving on one interface closes its own links first. It does + shorten every handshake on that interface: at R link requests a second, a link waiting there is closed after + about `max_incoming_links` / R seconds (1.3 s at 100 a second, at the default), so an honest initiator + reaching the node over the same interface whose handshake takes longer is cut off (RNS tells it at once, and + it may try again), while initiators on other interfaces are not affected. Each request still costs the node + the key exchange and a proof; RNS's own long responder timeout is the root cause. - rnfs neither sends nor accepts compressed Resources. Handlers are registered with `auto_compress=False`, and nodes MUST set RNS's maximum decompressed Resource size (`RNS.Resource.AUTO_COMPRESS_MAX_SIZE` in RNS 1.5.6) to 0, since RNS decompresses before any rnfs check runs. Setting it to 0 also stops RNS @@ -239,10 +261,12 @@ refused by them SHOULD be answered at once, before a response slot is reserved o way. A response refused for its size is not charged. - **Reservation and commitment.** When it is allowed, a response is charged a reservation of at most one packet (the link's MDU: its advertisement). The rest of it counts as committed, when admitting other - responses, while it is within its grace (Displacement, above) or once it has sent something, so responses - admitted together can't overrun the bucket. A response past its grace with nothing sent commits nothing: - responses a requester never downloads hold at most their reservations. A single-packet response, and one - whose progress can't be followed, is charged in full when it is allowed. + responses, while it is within its grace (Displacement, above), so responses admitted together can't + overrun the bucket. After its grace a response commits at most what it sends at its progress rate before + its deadline, and nothing while it is below its floor (or has sent nothing): responses a requester never + downloads, or crawls through, hold at most their reservations, so a few of them can't hold admission while + spending nothing. A single-packet response, and one whose progress can't be followed, is charged in full + when it is allowed. - **Per requester.** One requester (its identity; each unidentified link counts as its own) may have responses in flight costing at most half the bucket's capacity. Its response's block budget is cut to what is left of that share (the first block is always included, section 7), and a requester at its share @@ -584,8 +608,9 @@ and repeat. - `MAX_RESPONSE_SIZE − 1 KiB`, so the encoded response stays within `MAX_RESPONSE_SIZE`; - if the link has a rate estimate, `response_target` of transfer at the lower of that estimate and `max_upload_rate`; if it has none, `CHUNK_SIZE` (what a requester asks for then, enforced); - - with an upload budget whose bucket isn't full, what the bucket holds, less 1 KiB, and in any case what is - left of the requester's half share (section 3, Airtime controls). + - with an upload budget whose bucket isn't full or with responses in flight committed, what the bucket holds + less what they are committed to send, less 1 KiB, and in any case what is left of the requester's half + share (section 3, Airtime controls). - **What a node serves:** only blocks in the DAG of a CID it currently provides (the same reachability GC computes). With `pinned`, cached downloads answer `None`, so a node's download history can't be read by asking for it. With the default `roots`, complete cached downloads are provided and served, so they can. @@ -864,7 +889,7 @@ as is cheap. Rules live in the sections referenced. | Hiding content near a key | Provider search doesn't stop at the first reply that lists providers (6) | | Routing table takeover with new identities | Only proven-reachable nodes stored; full buckets keep long-lived nodes (6) | | Fake node IDs in replies | Unknown IDs never stored; at most `alpha` path requests for them per lookup; lookup time budget (6) | -| Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement protection earned by progress (a per-response grace, then a throughput floor), so a response that transfers nothing can't hold a slot; no-progress and stall cancellation with refund; a cooldown on a link whose response made no progress or was rejected unsent; requester stall detection (2, 3, 7) | +| Slow or stalled transfers (both directions) | Rates measured from transfers, never from round-trip time; response deadlines; displacement protection earned by progress (a per-response grace, then a throughput floor), so a response that transfers nothing can't hold a slot; no-progress and stall cancellation with refund; a cooldown on a link whose response made no progress, was rejected unsent, or fell far behind its schedule; many fast-link slots; requester stall detection (2, 3, 7) | | Request flooding, link exhaustion | Request size, per-link and global limits; a per-link request rate limit; eviction of idle incoming links; half-open links capped in their own bucket, oldest closed first (3) | | Decompression and oversized-response bombs | Compressed Resources refused; response sizes capped; multi-segment Resources refused on `rnfs.dht` links and on links to LXMF propagation nodes; `rnfs.dht` responses' msgpack headers checked before RNS decodes them (3, 7, 13) | | Reading another node's download history | Only provided content is served (7). Under the default `roots`, complete cached downloads are provided; `pinned` keeps them private | @@ -934,19 +959,29 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. - An attacker that behaves exactly like a slow-link client competes fairly for the slow pool's slots, and can't be told apart from one. - Displacement protection and the cooldown stop a requester holding a response slot without transferring, and - the cooldown binds a link and its identity. But each unidentified link is its own requester, so many links - that each take a slot, transfer nothing for one grace, and then sit out their cooldown can still keep a pool - busy: either fresh links, at about one per grace per slot (0.7 a second for the two fast slots over TCP, - whose grace is 3 s), or a fixed set of about `slots × (blocks_cooldown + grace) / grace` links (42 at the - defaults, within `max_incoming_links`), used in turn. Where they all arrive over one interface (a single - upstream transport node), nothing at admission tells them from honest requesters on the same interface: a - newcomer with no history looks the same either way. Limits on young links or on new links per interface - don't bind the fixed set and would hold back honest peers reaching the node through one transport node; a - waiting list giving freed slots to the requester refused longest ago only shares slots per link, which the - attacker multiplies (measured over real RNS: no change, honest refusal 0.95–1.0 either way). So the fast - pool can still be denied this way. The hold is no longer free: the attacker must keep links open and - polling, or keep opening them. An operator under such an attack can raise `blocks_cooldown`, so that a fixed - set would need more links than `max_incoming_links` admits, leaving only fresh links to do it. + the cooldown binds a link and its identity. But each unidentified link is its own requester, and where links + all arrive over one interface (a single upstream transport node, or every link of a node attached to a + shared instance), nothing at admission tells them from honest requesters on it: a newcomer with no history + looks the same either way. So a pool can still be kept busy in two ways (Medium). Measured with the test + fakes over 10 simulated minutes, an honest requester fetching every ~10 s (three tries 2 s apart) and the + attacker asking every 50 ms over the same TCP interface, reporting two hops: + - **Slow downloaders.** Links that each download their responses at about the rate they need to be done when + due keep their slots: 18 links pulling ~1.3 kB/s each (~21 kB/s in all) held the 16 fast slots, and the + honest requester was refused 95% of the time. At 300 B/s each, or taking one part of each response and + renewing it from a spare link, they were cooled down (refused 4–7%; before, 3 links taking one part each, + ~90 B/s in all, held the then two fast slots: 98%). + - **Fresh-link churn.** A new unidentified link for each request, holding a slot for a grace (or, taking one + part, until displaced as the slowest after `deadline_slack`) and never asking again: one new link a second + taking one part each (~0.7 kB/s) left the honest requester refused 35% of the time, two a second 63%, and + ten a second taking nothing 81%. + A fixed set of links used in turn needs about `slots × (blocks_cooldown + hold) / hold` links (176 for the + fast pool at the defaults, with a 30 s hold), more than `max_incoming_links` admits; 40 links taking one + part each were refused 11% of the time. Limits on young links or on new links per interface would hold back + honest peers reaching the node through one transport node; a waiting list giving freed slots to the + requester refused longest ago only shares slots per link, which the attacker multiplies (measured over real + RNS: no change, honest refusal 0.95–1.0 either way). The hold is not free: the attacker must keep + downloading, or keep opening links. An operator under such an attack can raise `fast_blocks_responses`, or + `blocks_cooldown`. - An outside web site can frame a gateway page and receive public content from it by `postMessage` (outside private mode, only content public by CID). @@ -1141,10 +1176,11 @@ constants are fixed in section 5; they matter only to writers that choose them a **Local settings.** Each node may change these. Settings have exact types: whole numbers are integers (`true` is not 1), flags are `true` or `false`, rates (bits/s) and times (seconds, at most 30 days) are finite numbers. Ports are 1–65535 (`gateway_port` 0: any free port); `alpha`, `beta`, `max_parallel_providers`, -`max_failures`, `max_incoming_links` and `min_rate_sample` are at least 1; other sizes and counts at least 0; -rates above 0; times above 0, except `startup_announce_delay`, `provider_seniority`, `deadline_slack`, -`blocks_cooldown` and `ban_time`, which may be 0. `members` and `remote_pin_identities` are lists of identity hashes (32 hex -digits). A node MUST NOT start with an invalid setting. +`max_failures`, `max_incoming_links`, `fast_blocks_responses` and `min_rate_sample` are at least 1; other +sizes and counts at least 0; rates above 0; times above 0, except `startup_announce_delay`, +`provider_seniority`, `deadline_slack`, `blocks_cooldown` and `ban_time`, which may be 0. `members` and +`remote_pin_identities` are lists of identity hashes (32 hex digits). A node MUST NOT start with an invalid +setting. | Name | Default | Notes | |---|---|---| @@ -1163,13 +1199,14 @@ digits). A node MUST NOT start with an invalid setting. | `response_target` | 60 s | Transfer time a `/blocks` response aims for | | `deadline_slack` | 30 s | Added to response deadlines; minimum age before a response can be displaced | | `stall_timeout` | 120 s | A response with no progress for this long is cancelled and refunded | -| `blocks_cooldown` | 60 s | A link (and its identity) whose response made no progress is refused `/blocks` this long | +| `blocks_cooldown` | 300 s | A link (and its identity) whose response made no progress is refused `/blocks` this long | | `lookup_timeout` | 10 min | | | `path_timeout` | 30 s | Waiting for an RNS path | | `max_failures` | 3 | Unanswered queries in a row before a node is dropped; busy refusals in a row before a provider fails | | `link_idle_timeout` | 5 min | | | `max_incoming_links` | 128 | | -| `max_blocks_responses` | 4 | `/blocks` responses in progress, in total; half for slow links. At least 2 | +| `max_blocks_responses` | 4 | `/blocks` responses in progress on slow links: half of this; the rest for other links, unless `fast_blocks_responses` is more. At least 2 | +| `fast_blocks_responses` | 16 | `/blocks` responses in progress on links that aren't slow, if more than the rest of `max_blocks_responses` | | `max_upload_rate` | none; half the rate estimate on slow links that have one | | | `max_records` | 100 000 | | | `ban_time` | 1 h | | From 2290b6649d9177184a320bf61f41c3825e09801e Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:23:22 +1030 Subject: [PATCH 082/107] Bound the file nodes a byte-range read meets and the blocks a listing fetches, and credit directory entries their own bytes plus 16 --- rnfs/fetcher.py | 203 ++++++++++++++++------ tests/security/_fetchr2_helpers.py | 65 +++++++ tests/security/test_n1_entry_credit.py | 71 ++++++++ tests/security/test_n1_listing_chains.py | 108 ++++++++++++ tests/security/test_n1_read_range_cap.py | 207 +++++++++++++++++++++++ 5 files changed, 599 insertions(+), 55 deletions(-) create mode 100644 tests/security/_fetchr2_helpers.py create mode 100644 tests/security/test_n1_entry_credit.py create mode 100644 tests/security/test_n1_listing_chains.py create mode 100644 tests/security/test_n1_read_range_cap.py diff --git a/rnfs/fetcher.py b/rnfs/fetcher.py index 38e6a51..45d87b7 100644 --- a/rnfs/fetcher.py +++ b/rnfs/fetcher.py @@ -35,12 +35,17 @@ a fetch downloads (a DAG of empty files has size 0 but can hold gigabytes of directory nodes); listings and write-outs stop at max_output_entries entries met while fetching. fetch_dag also bounds the bytes of the DAG's distinct blocks by what its declared size can hold (dag_bound: the size, DAG_GRANULE_OVERHEAD per -DAG_GRANULE of it and DAG_SLACK), plus DAG_ENTRY_OVERHEAD per directory entry or index child met, for at most -max_output_entries of them; a caller's max_dag_bytes (a quota) can lower it. A file node may list children -of size 0, so without it a file declaring 1 byte could hold any number of distinct nodes. What the builder -writes never comes near it (design.md, "DAG byte bound"). While it applies, requests ask for no more than it -leaves, counting a DAG node as MAX_BLOCK_SIZE until it arrives, so a fetch downloads at most about one block -beyond it before failing with TooLarge. max_storage never refuses a fetch: one +DAG_GRANULE of it and DAG_SLACK), plus what its directory and index nodes bring (node_credit: their own +bytes and DAG_ENTRY_OVERHEAD per entry or index child, less INDEX_NODE_COST for an index node); a +caller's max_dag_bytes (a quota) can lower it. fetch_listing has the same bound for a size of 0, and each +read_range call bounds the bytes of the file nodes it meets by range_bound (what the range can hold, plus two +full file nodes per level a file of the declared size can have). A file node may list children of size 0, +and index nodes can be chained, so without these a file declaring 1 byte, or a directory of one entry, could +hold any number of distinct nodes. What the builder writes never comes near them (design.md, "DAG byte +bound"). While one applies, requests ask for no more than it leaves, counting a DAG node as MAX_BLOCK_SIZE +until it arrives (a directory or index node as nothing under the size-derived bound: see _reserve), so a +fetch downloads at most about one block beyond it before failing with TooLarge. max_storage never refuses a +fetch: one that isn't being pinned evicts other cached fetches to make room for each response, and goes over max_storage if nothing is evictable (the node evicts again once it ends). What refuses a fetch, pinned or not, with StorageFull is the disk: its remaining size, and then each response, must leave the disk reserve @@ -62,7 +67,7 @@ from typing import Callable, Iterator from rnfs.config import Config -from rnfs.constants import CHUNK_SIZE, KIB, MAX_BLOCK_SIZE, MAX_REQUEST_SIZE +from rnfs.constants import CHUNK_SIZE, KIB, MAX_BLOCK_SIZE, MAX_CHILDREN, MAX_DEPTH, MAX_REQUEST_SIZE from rnfs.content import walker from rnfs.content.cid import Cid from rnfs.content.storage import Storage, read_json, write_json @@ -86,18 +91,63 @@ # costs at most 51 bytes of file node (a [cid, size] child), plus about 1/255 of that for the levels above DAG_GRANULE = CHUNK_SIZE // 16 # 4 KiB of declared content, the smallest chunk SmallLeaves counts as large... DAG_GRANULE_OVERHEAD = 64 # ...may bring this many bytes of file nodes (an honest file needs under 13) -DAG_ENTRY_OVERHEAD = 1 * KIB # bytes allowed per directory entry or index child met (an honest one needs ~830) -DAG_SLACK = 1 * KIB # the target's own node header and the file node levels above its chunks (under 600) -NODE_ENTRIES = MAX_BLOCK_SIZE // 43 # entries a directory node can hold at most (43 bytes is the smallest) +# The target's own node header and the file node levels above its chunks (under 600), or the index nodes of a +# directory met before its entries (under 336) +DAG_SLACK = 1 * KIB +# A directory or index node pays for itself and brings this much per entry or child: room for the index nodes' +# own cost below and an empty directory's 3 bytes. An index child leads to entries of its own, which an index +# node is met before, so it brings its share in advance +DAG_ENTRY_OVERHEAD = 16 +# What an index node takes from that: a chain of single-child index nodes costs 48 bytes a level, and nodes of 4 +# children or more pay for themselves. The tree rule fills every index node but the last of its level, so an +# honest directory meets at most one that costs anything per level (7 levels for 2^64 entries) before its entries +INDEX_NODE_COST = 64 +# The largest file node the tree rule writes: its header (array, type, array16) and MAX_CHILDREN children of at +# most 51 bytes ([cid, size]: a 39-byte CID and a 9-byte size, with their headers) +FILE_NODE_MAX = 5 + MAX_CHILDREN * 51 def dag_bound(size: int) -> int: """The most bytes of distinct blocks the DAG of a target declaring size content bytes may hold, before - the allowance its directory entries bring (Fetch.fetch_dag): size, DAG_GRANULE_OVERHEAD per DAG_GRANULE - of it, and DAG_SLACK.""" + the allowance its directory and index nodes bring (Fetch.fetch_dag): size, DAG_GRANULE_OVERHEAD per + DAG_GRANULE of it, and DAG_SLACK.""" return size + DAG_GRANULE_OVERHEAD * -(-size // DAG_GRANULE) + DAG_SLACK +def node_credit(length: int, children: list[Expect]) -> int: + """What a counted DAG node of length bytes with these children brings to the DAG byte bound: a directory + node its own bytes plus DAG_ENTRY_OVERHEAD per entry, an index node the same per child less INDEX_NODE_COST + (it costs at most that), a file node nothing (the content it holds pays for it).""" + entries = sum(1 for child in children if child.kind is not None) + if entries: + return length + DAG_ENTRY_OVERHEAD * entries + index_children = sum(1 for child in children if child.first_name is not None) + if index_children: + return max(0, length + DAG_ENTRY_OVERHEAD * index_children - INDEX_NODE_COST) + return 0 + + +def file_levels(size: int) -> int: + """The most file node levels the tree rule gives a file of size bytes whose chunks, but the last, hold at + least DAG_GRANULE: enough MAX_CHILDREN-wide levels for size // DAG_GRANULE + 1 chunks (7 at most).""" + chunks = size // DAG_GRANULE + 1 + levels = 1 + while MAX_CHILDREN ** levels < chunks and levels < MAX_DEPTH: + levels += 1 + return levels + + +def range_bound(size: int, length: int) -> int: + """The most bytes of file nodes reading length bytes of a file declaring size bytes may meet (read_range). + + The nodes wholly inside the range cost what dag_bound(length) allows, as in a whole file; on each level + the range can only end inside two more, and an honest file of size bytes has at most file_levels(size) + levels of nodes of at most FILE_NODE_MAX bytes. Raw chunks aren't counted: a range's chunks hold its bytes + and at most two chunks' worth more. + """ + return dag_bound(length) + 2 * file_levels(size) * FILE_NODE_MAX + + class Cancelled(RnfsError): """A fetch stopped because its observer asked it to (a job cancelled, or the node stopping).""" @@ -133,6 +183,29 @@ def observing(observer: Observer): _observers.current = previous +class _RangeBudget: + """The file node bytes one read_range call may still meet (range_bound), and the nodes it has met.""" + + __slots__ = ("limit", "length", "used", "counted") + + def __init__(self, limit: int, length: int): + """A budget of limit bytes for reading length bytes.""" + self.limit = limit + self.length = length + self.used = 0 + self.counted: set[Cid] = set() + + def count(self, root: Cid, cid: Cid, length: int): + """Count a DAG node of length bytes once. Raises TooLarge once over the limit.""" + if cid in self.counted: + return + self.counted.add(cid) + self.used += length + if self.used > self.limit: + raise TooLarge(f"reading {plural(self.length, 'byte')} of {root} would take more than {self.limit} " + "bytes of file nodes, more than such a range can need: refusing it") + + class _Provider: """What a fetch knows about one provider.""" @@ -261,9 +334,11 @@ def __init__(self, fetcher: Fetcher, root: Cid, names: list[str], from_node: byt # (a remote pin's quota); TooLarge once over. fetch_dag adds the bound its target's size implies self.max_dag_bytes: int | None = None self.dag_bytes = 0 - self._declared: int | None = None # content bytes the targets of fetch_dag declare, once it runs - self._entries = 0 # directory entries and index children of the counted blocks + # Content bytes the targets of fetch_dag declare, once it runs (fetch_listing's count as 0) + self._declared: int | None = None + self._credit = 0 # what the counted directory and index nodes bring to the bound (node_credit) self._counted: set[Cid] = set() + self._range: _RangeBudget | None = None # the budget of the read_range call ensuring blocks, if any self._progress_at = fetcher.clock() # last verified block or response progress, for the no-progress deadline self.root_delivered = False self.closed = False @@ -416,11 +491,15 @@ def fetch_dag(self, target: Expect, max_entries: int | None = None): def fetch_listing(self, target: Expect, max_entries: int | None = None): """Fetch the directory and index nodes of the directory target (not its entries' contents). - Raises TooLarge once more than max_entries (default max_output_entries) entries are met. + Raises TooLarge once more than max_entries (default max_output_entries) entries are met, or once its + blocks hold more than dag_limit() bytes: a listing's blocks are bounded as fetch_dag's are for a + target of size 0 (its directory nodes pay for themselves, a chain of index nodes doesn't). """ if max_entries is None: max_entries = self.config.max_output_entries with self._checking(): + if self._declared is None: + self._declared = 0 # from here on its blocks are counted, the target's again frontier = [target] seen = {target} entries = 0 @@ -453,7 +532,9 @@ def read_range(self, target: Expect, offset: int = 0, length: int | None = None) and the nodes above, WINDOW chunks at a time. Raises ValueError for a directory. Lazy all the way down: the range is walked depth-first as it is read, so a DAG claiming billions of - chunks costs nothing until they are read, and small chunks count against max_output_entries. + chunks costs nothing until they are read, and small chunks count against max_output_entries. The file + nodes it meets, stored or downloaded, may hold range_bound(size, range length) bytes (TooLarge past + it): a node can be padded with children of size 0, which the walk skips but must download. """ if offset < 0 or (length is not None and length < 0): raise ValueError("offset and length must be non-negative") @@ -467,9 +548,12 @@ def read_range(self, target: Expect, offset: int = 0, length: int | None = None) return self.check_size(end - offset) self.check_disk(target, end - offset) + budget = _RangeBudget(range_bound(size, end - offset), end - offset) + if not target.cid.is_raw: + budget.count(self.root, target.cid, len(self.get(target))) small = walker.SmallLeaves(self.config.max_output_entries) window: list[tuple[Expect, int, int]] = [] - for leaf in self._range_leaves(target, offset, end - offset): + for leaf in self._range_leaves(target, offset, end - offset, budget): small.meet(leaf[2]) window.append(leaf) if len(window) >= WINDOW: @@ -494,10 +578,11 @@ def _read_window(self, window: list[tuple[Expect, int, int]]) -> Iterator[bytes] walker.check_raw(expect, len(block)) yield block[child_offset:child_offset + child_length] - def _range_leaves(self, target: Expect, offset: int, length: int) -> Iterator[tuple[Expect, int, int]]: + def _range_leaves(self, target: Expect, offset: int, length: int, + budget: _RangeBudget) -> Iterator[tuple[Expect, int, int]]: """The raw chunks covering a range, in order, each with the range within it, depth-first; the DAG - nodes below each node are fetched together when it is reached (the stack holds at most - MAX_DEPTH × MAX_CHILDREN items).""" + nodes below each node are fetched together when it is reached, counted against budget (the stack + holds at most MAX_DEPTH × MAX_CHILDREN items).""" stack: list[tuple[Expect, int, int]] = [(target, offset, length)] while stack: expect, child_offset, child_length = stack.pop() @@ -505,7 +590,12 @@ def _range_leaves(self, target: Expect, offset: int, length: int) -> Iterator[tu yield expect, child_offset, child_length continue parts = walker.range_children(expect, self.get(expect), child_offset, child_length) - self.ensure([child for child, _, _ in parts if not child.cid.is_raw]) + # Only while ensuring: another read_range of this fetch may run between this generator's steps + self._range = budget + try: + self.ensure([child for child, _, _ in parts if not child.cid.is_raw]) + finally: + self._range = None stack.extend(reversed(parts)) # Getting blocks @@ -564,72 +654,75 @@ def _check_stored(self, expect: Expect) -> list[Expect] | None: return result def dag_limit(self) -> int | None: - """Bytes the distinct blocks of the DAG may hold: max_dag_bytes, and once fetch_dag runs, dag_bound of - the size its target declares plus DAG_ENTRY_OVERHEAD per directory entry or index child met (at most - max_output_entries of them), whichever is lower. None if neither applies.""" + """Bytes the distinct blocks of the DAG may hold: max_dag_bytes, and once fetch_dag (or fetch_listing) + runs, dag_bound of the size its target declares plus what the directory and index nodes counted bring + (node_credit), whichever is lower. None if neither applies.""" limits = [] if self.max_dag_bytes is None else [self.max_dag_bytes] if self._declared is not None: - entries = min(self._entries, self.config.max_output_entries) - limits.append(dag_bound(self._declared) + DAG_ENTRY_OVERHEAD * entries) + limits.append(dag_bound(self._declared) + self._credit) return min(limits, default=None) def _count_dag(self, cid: Cid, length: int, children: list[Expect] = ()): - """Count a block of the DAG once against dag_limit(), if there is one, with the directory entries and - index children among its children. Raises TooLarge once over.""" + """Count a block of the DAG once against dag_limit(), if there is one, with what it brings to it + (node_credit), and a DAG node against the budget of the read_range call ensuring it, if any. Raises + TooLarge once over either.""" + if self._range is not None and not cid.is_raw: + self._range.count(self.root, cid, length) if (self.max_dag_bytes is None and self._declared is None) or cid in self._counted: return self._counted.add(cid) self.dag_bytes += length - self._entries += sum(1 for child in children if child.kind is not None or child.first_name is not None) + self._credit += node_credit(length, children) limit = self.dag_limit() if self.dag_bytes > limit: if limit == self.max_dag_bytes: raise TooLarge(f"{self.root} holds more than {limit} bytes of blocks") - raise TooLarge(f"{self.root} holds more than {limit} bytes of blocks, more than its " - f"{plural(self._declared, 'byte')} of content and its directory entries can take: " - "refusing it") + content = f"its {plural(self._declared, 'byte')} of content and " if self._declared else "" + raise TooLarge(f"{self.root} holds more than {limit} bytes of blocks, more than {content}its " + "directory entries can take: refusing it") def _room(self, wanted: dict[Cid, list[Expect]]) -> tuple | None: """What the limits leave for blocks not yet asked for, after the most the requests in flight can bring: - (bytes under the size-derived bound, entries it may still credit, bytes under max_dag_bytes), each None - if that limit doesn't apply; None if neither does.""" - if self._declared is None and self.max_dag_bytes is None: + (bytes under the size-derived bound, bytes under max_dag_bytes, bytes under the budget of the + read_range call ensuring blocks), each None if that limit doesn't apply; None if none does.""" + if self._declared is None and self.max_dag_bytes is None and self._range is None: return None - bound = None - if self._declared is not None: - entries = min(self._entries, self.config.max_output_entries) - bound = dag_bound(self._declared) + DAG_ENTRY_OVERHEAD * entries - self.dag_bytes + bound = None if self._declared is None else dag_bound(self._declared) + self._credit - self.dag_bytes quota = None if self.max_dag_bytes is None else self.max_dag_bytes - self.dag_bytes - room = (bound, self.config.max_output_entries - self._entries, quota) + budget = None if self._range is None else self._range.limit - self._range.used + room = (bound, quota, budget) for cids, _ in self._in_flight.values(): for cid in cids: room = self._reserve(cid, wanted, room) return room def _reserve(self, cid: Cid, wanted: dict[Cid, list[Expect]], room: tuple) -> tuple: - """room (as _room) after the most cid can add to the DAG's bytes: a raw chunk's declared size, otherwise - MAX_BLOCK_SIZE; under the size-derived bound, a directory or index node takes only its header while - enough entries are left for the most a node holds, since each brings more allowance than it takes.""" - bound, entries, quota = room - expects = wanted.get(cid) + """room (as _room) after the most cid can add to the bytes counted: a raw chunk's declared size, + otherwise MAX_BLOCK_SIZE, and nothing for a raw chunk under a read_range budget. + + Under the size-derived bound a directory or index node takes nothing: a directory node brings more + than it takes, and an index node takes at most INDEX_NODE_COST, so a request of MAX_BATCH of them + overshoots by at most 2 KiB. Holding them to INDEX_NODE_COST each would ask for a directory's + subdirectories a few at a time. + """ + bound, quota, budget = room + if budget is not None and not cid.is_raw and cid not in self._range.counted: + budget -= MAX_BLOCK_SIZE if cid in self._counted: - return room + return bound, quota, budget + expects = wanted.get(cid) worst = MAX_BLOCK_SIZE if expects and cid.is_raw and expects[0].size is not None: worst = expects[0].size - if bound is not None: - if (expects and not cid.is_raw and entries >= NODE_ENTRIES - and not any(walker.FILE_TYPE in expect.types for expect in expects)): - bound, entries = bound - 8, entries - NODE_ENTRIES - else: - bound -= worst - return bound, entries, None if quota is None else quota - worst + if bound is not None and not (expects and not cid.is_raw + and not any(walker.FILE_TYPE in expect.types for expect in expects)): + bound -= worst + return bound, None if quota is None else quota - worst, budget @staticmethod def _fits(room: tuple) -> bool: """True if room (as _room) leaves nothing below zero.""" - bound, _, quota = room - return (bound is None or bound >= 0) and (quota is None or quota >= 0) + return all(left is None or left >= 0 for left in room) def _accept(self, cid: Cid, block: bytes, wanted: dict[Cid, list[Expect]], children: dict): """Check a verified block against every expectation of it and the limits, then store it. diff --git a/tests/security/_fetchr2_helpers.py b/tests/security/_fetchr2_helpers.py new file mode 100644 index 0000000..cf8405a --- /dev/null +++ b/tests/security/_fetchr2_helpers.py @@ -0,0 +1,65 @@ +"""Shared helpers for the fetch-path bound tests (N1-R1, N1-R2, N1-R3; not a test module): hand-made DAGs whose +nodes are padded with empty children or chained, and the bound a byte-range read may use, computed here from the +protocol constants rather than taken from rnfs.fetcher, so the tests also check its formula.""" +from rnfs.constants import MAX_BLOCK_SIZE, MAX_CHILDREN, MAX_DEPTH +from rnfs.content import dag +from rnfs.content.cid import DAG, EMPTY_FILE, RAW, Cid +from rnfs.fetcher import DAG_GRANULE, dag_bound + +PAD = 3000 # empty children per padded node: about 118 KiB of node, under MAX_BLOCK_SIZE +# The largest file node the tree rule writes: MAX_CHILDREN children of the longest CID and largest size +FULL_FILE_NODE = len(dag.encode(dag.FileNode([dag.Child(Cid.for_block(DAG, b"%d" % i), dag.MAX_SIZE) + for i in range(MAX_CHILDREN)]))) + + +def file_levels(size: int) -> int: + """File node levels the tree rule gives a file of size bytes cut into chunks of at least DAG_GRANULE (its last + may be shorter): the smallest height whose MAX_CHILDREN-wide levels hold size // DAG_GRANULE + 1 chunks.""" + chunks = size // DAG_GRANULE + 1 + levels = 1 + while MAX_CHILDREN ** levels < chunks and levels < MAX_DEPTH: + levels += 1 + return levels + + +def range_allowance(size: int, length: int) -> int: + """The most bytes of file nodes reading length bytes of a file declaring size may meet: what the range itself + can hold (dag_bound), plus two full file nodes (the range's two ends) on each level the file can have.""" + return dag_bound(length) + 2 * file_levels(size) * FULL_FILE_NODE + + +class Blocks: + """Blocks for A to serve, built by hand.""" + + def __init__(self): + """Just the empty chunk, which padded nodes list.""" + self.blocks: dict[Cid, bytes] = {EMPTY_FILE: b""} + + def raw(self, data: bytes) -> Cid: + """A raw chunk.""" + cid = Cid.for_block(RAW, data) + self.blocks[cid] = data + return cid + + def node(self, node) -> Cid: + """A DAG node.""" + block = dag.encode(node) + assert len(block) <= MAX_BLOCK_SIZE, len(block) + cid = Cid.for_block(DAG, block) + self.blocks[cid] = block + return cid + + def chain(self, leaf: Cid, size: int, levels: int, pad: int = PAD) -> Cid: + """levels file nodes above leaf (size bytes), each with that one child and pad empty children after it. + Every level's node differs (it lists a different child), so each is a distinct block to fetch.""" + cid = leaf + for _ in range(levels): + cid = self.node(dag.FileNode([dag.Child(cid, size)] + [dag.Child(EMPTY_FILE, 0)] * pad)) + return cid + + def index_chain(self, name: str, levels: int) -> Cid: + """A directory holding one empty file called name, below levels index nodes of one child each.""" + cid = self.node(dag.DirectoryNode([dag.Entry(name, EMPTY_FILE, 0, dag.KIND_FILE)])) + for _ in range(levels): + cid = self.node(dag.IndexNode([dag.IndexChild(name, cid, 0)])) + return cid diff --git a/tests/security/test_n1_entry_credit.py b/tests/security/test_n1_entry_credit.py new file mode 100644 index 0000000..88885f3 --- /dev/null +++ b/tests/security/test_n1_entry_credit.py @@ -0,0 +1,71 @@ +"""N1-R3: fetch_dag's DAG byte bound credited 1 KiB per directory entry met, whatever the entry, so a directory +declaring 0 bytes whose entries all point at the same empty file could carry about 20 times its own bytes of other +blocks (about 100 MB at max_output_entries); and since the credit stopped at max_output_entries entries, an honest +directory of more than about 331,000 long-named entries could no longer be pinned. + +B pins a 0-byte directory of 2,600 entries pointing at the empty file plus 20 distinct 118 KB file nodes of empty +chunks: it must be refused with TooLarge after downloading at most the directory, 16 bytes per entry, dag_bound(0) +and about one block. B, with max_output_entries 1,000, pins an honest directory of 4,000 entries with the longest +names (1.2 MB of directory nodes, more than 1,000 credits of 1 KiB): every entry now brings its own bytes, so it is +pinned (get and export of it are still refused by max_output_entries). +""" +import os +import unittest + +from rnfs.constants import MAX_BLOCK_SIZE +from rnfs.content import builder, dag +from rnfs.content.cid import EMPTY_FILE +from rnfs.content.storage import USER +from rnfs.errors import TooLarge +from rnfs.fetcher import dag_bound +from tests.security._content_helpers import TwoNodeTest +from tests.security._fetchr2_helpers import PAD, Blocks +from tests.test_node import A_ID + +DUPLICATES = 2600 +PER_ENTRY = 16 # what each entry may bring beyond its own bytes + + +class DuplicateEntriesTest(TwoNodeTest): + """A 0-byte directory whose many entries are the same empty file, beside a few large nodes of empty chunks.""" + + def test_pin_is_refused(self): + blocks = Blocks() + entries = [dag.Entry(f"{i:05d}", EMPTY_FILE, 0, dag.KIND_FILE) for i in range(DUPLICATES)] + junk = [blocks.node(dag.FileNode([dag.Child(EMPTY_FILE, 0)] * (PAD - i))) for i in range(20)] + entries += [dag.Entry(f"z{i:04d}", cid, 0, dag.KIND_FILE) for i, cid in enumerate(junk)] + directory = blocks.node(dag.DirectoryNode(entries)) + self.serve(blocks.blocks, directory) + with self.assertRaises(TooLarge, msg="the pin went ahead: the entries' credit paid for the junk") as caught: + self.b.pin_add(str(directory)) + self.assertIn("refusing it", str(caught.exception)) + allowed = len(blocks.blocks[directory]) + PER_ENTRY * len(entries) + dag_bound(0) + MAX_BLOCK_SIZE + self.assertLessEqual(self.net.downloaded_bytes, allowed) + self.assertNotIn(directory, self.b.storage.pins()) + + +class LargeHonestDirectoryTest(TwoNodeTest): + """A directory with more entries than max_output_entries, each with the longest name.""" + + b_settings = {"max_output_entries": 1000} + + def test_pin_goes_ahead(self): + with self.a.storage.protect() as session: + empty = builder.build_bytes(b"", session.put).cid + built = builder.build_directory([dag.Entry(f"{i:05d}".ljust(255, "n"), empty, 0, dag.KIND_FILE) + for i in range(4000)], session.put) + self.a.storage.pin(built.cid, USER) + self.a.publisher.refresh() + self.a.records.store(built.cid.key, A_ID) + try: + self.b.pin_add(str(built.cid)) + except TooLarge as e: + self.fail(f"an honest directory larger than max_output_entries wasn't pinned: {e}") + self.assertIn(USER, self.b.storage.pins()[built.cid]) + self.assertTrue(self.b.storage.is_complete(built.cid)) + with self.assertRaises(TooLarge): # more entries than a write-out may have + self.b.get(str(built.cid), os.path.join(self.tmp.name, "out")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_n1_listing_chains.py b/tests/security/test_n1_listing_chains.py new file mode 100644 index 0000000..2639d4c --- /dev/null +++ b/tests/security/test_n1_listing_chains.py @@ -0,0 +1,108 @@ +"""N1-R2: fetch_listing (`ls`, the gateway's directory listings) counted index children per level, not in total, so +chains of single-child index nodes down to MAX_DEPTH were never counted: `ls` of 40 entries downloaded 751 KB in +2,441 blocks, and with more chains about 1.9 GB. + +A serves a directory of 40 entries, each below a chain of 20 index nodes of one child; B lists it. The listing is +now bounded like a whole-DAG fetch of size 0: its directory and index nodes pay for themselves and bring a few bytes +per entry or child, but an index node of one child costs 48 bytes, so it must be refused with TooLarge having +downloaded at most the root, dag_bound(0) and about one block more. Honest listings (a wide directory with index nodes and the longest names, one of exactly +max_output_entries entries, one of two index levels) still list in full. +""" +import os +import unittest + +from rnfs.constants import MAX_BLOCK_SIZE, MAX_CHILDREN +from rnfs.content import builder, dag +from rnfs.content.cid import EMPTY_FILE, Cid +from rnfs.content.storage import USER +from rnfs.errors import TooLarge +from rnfs.fetcher import dag_bound +from tests.security._content_helpers import TwoNodeTest +from tests.security._fetchr2_helpers import Blocks +from tests.test_node import A_ID + +CHAINS = 40 +LEVELS = 20 # the reviewer used 60 (to MAX_DEPTH); fewer keep the test fast and still far over the bound + + +def name(i: int, length: int = 255) -> str: + """A distinct entry name of length bytes.""" + return f"{i:05d}".ljust(length, "n") + + +class IndexChainListingTest(TwoNodeTest): + """ls of a directory whose entries each sit below a chain of single-child index nodes.""" + + def test_ls_of_index_chains_is_refused(self): + blocks = Blocks() + tops = [(name(i), blocks.index_chain(name(i), LEVELS)) for i in range(CHAINS)] + root = blocks.node(dag.IndexNode([dag.IndexChild(entry, cid, 0) for entry, cid in tops])) + self.serve(blocks.blocks, root) + with self.assertRaises(TooLarge, msg="the listing finished: index chains aren't bounded") as caught: + self.b.ls(str(root)) + self.assertIn("refusing it", str(caught.exception)) + allowed = len(blocks.blocks[root]) + dag_bound(0) + MAX_BLOCK_SIZE + self.assertLessEqual(self.net.downloaded_bytes, allowed, + f"{self.net.downloaded_bytes} bytes downloaded in {self.net.downloaded_blocks} blocks") + + +class HonestListingTest(TwoNodeTest): + """Directories the builder writes list in full.""" + + b_settings = {"max_output_entries": 3 * MAX_CHILDREN + 1} + + def build(self, count: int, length: int) -> Cid: + """A directory of count empty files with names of length bytes, built on A and advertised.""" + with self.a.storage.protect() as session: + empty = builder.build_bytes(b"", session.put).cid + built = builder.build_directory([dag.Entry(name(i, length), empty, 0, dag.KIND_FILE) + for i in range(count)], session.put) + self.a.storage.pin(built.cid, USER) + self.a.publisher.refresh() + self.a.records.store(built.cid.key, A_ID) + return built.cid + + def test_wide_directory_with_the_longest_names(self): + cid = self.build(3 * MAX_CHILDREN + 1, 255) # four groups under an index node, max_output_entries in all + self.assertIsInstance(dag.decode(self.a.storage.get(cid)), dag.IndexNode) + entries = self.b.ls(str(cid)) + self.assertEqual([entry["name"] for entry in entries], [name(i) for i in range(3 * MAX_CHILDREN + 1)]) + self.assertEqual({entry["cid"] for entry in entries}, {str(EMPTY_FILE)}) + + def test_small_directories(self): + for count, length in [(0, 1), (1, 1), (MAX_CHILDREN, 1), (MAX_CHILDREN + 1, 5)]: + self.assertEqual(len(self.b.ls(str(self.build(count, length)))), count) + + def test_gateway_listing(self): + path = os.path.join(self.tmp.name, "tree") + os.makedirs(os.path.join(path, "sub")) + for i in range(MAX_CHILDREN + 2): + with open(os.path.join(path, name(i, 40)), "wb") as f: + f.write(b"%d" % i) + cid = Cid.parse(self.a.add_path(path)["cid"]) + self.a.records.store(cid.key, A_ID) + self.assertEqual(len(self.b.ls(str(cid), for_gateway=True)), MAX_CHILDREN + 3) + + +class TwoIndexLevelsTest(TwoNodeTest): + """A directory of MAX_CHILDREN^2 + 1 entries: a root index node above two (one of a single child) above the + groups, all met before any entry pays its share.""" + + b_settings = {"max_output_entries": MAX_CHILDREN * MAX_CHILDREN + 1} + + def test_ls_and_pin(self): + count = MAX_CHILDREN * MAX_CHILDREN + 1 + with self.a.storage.protect() as session: + empty = builder.build_bytes(b"", session.put).cid + built = builder.build_directory([dag.Entry(f"{i:06d}", empty, 0, dag.KIND_FILE) for i in range(count)], + session.put) + self.a.storage.pin(built.cid, USER) + self.a.publisher.refresh() + self.a.records.store(built.cid.key, A_ID) + self.assertEqual(len(self.b.ls(str(built.cid))), count) + self.b.pin_add(str(built.cid)) + self.assertTrue(self.b.storage.is_complete(built.cid)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_n1_read_range_cap.py b/tests/security/test_n1_read_range_cap.py new file mode 100644 index 0000000..2052760 --- /dev/null +++ b/tests/security/test_n1_read_range_cap.py @@ -0,0 +1,207 @@ +"""N1-R1: Fetch.read_range had no byte bound beyond max_download_size, so a small read of a file whose nodes are +padded with empty (size 0) children downloaded whole padded nodes all the way down: `cat` of a file declaring 1 byte +took 7.3 MB, and `get` of a file sealed to the node (decrypted through read_range, never fetch_dag) 29 MB for 12 KB. + +A serves files whose path to each chunk runs through padded file nodes; B reads them every way that goes through +read_range: cat (node.read), a gateway read, info's sealed-header read and get of a file sealed to B. Each must be +refused with TooLarge having downloaded at most the root block, the read's bound (dag_bound of the range plus two +full file nodes per level an honest file of the declared size can have; _fetchr2_helpers.range_allowance) and +about one block more. Honest files (both chunkers, a file of two node levels, sealed files) are read at many +offsets and lengths, downloading the same blocks as with no bound (in at most one more request) and using under +half of it. +""" +import io +import os +import random +import unittest +from unittest import mock + +import RNS + +from rnfs import fetcher +from rnfs.constants import CHUNK_SIZE, MAX_BLOCK_SIZE, MAX_CHILDREN +from rnfs.content import dag, seal +from rnfs.content.cid import Cid +from rnfs.errors import TooLarge +from tests.security._content_helpers import TwoNodeTest +from tests.security._fetchr2_helpers import FULL_FILE_NODE, Blocks, file_levels, range_allowance +from tests.test_node import A_ID + +LEVELS = 20 # padded levels below each root: about 2.4 MB of nodes per chain + + +class RangeBoundFormulaTest(unittest.TestCase): + """The bound read_range uses is the one derived in _fetchr2_helpers from the constants.""" + + def test_formula(self): + range_bound = getattr(fetcher, "range_bound", None) + self.assertIsNotNone(range_bound, "rnfs.fetcher has no range_bound") + for size, length in [(1, 1), (CHUNK_SIZE, 10), (MAX_CHILDREN * CHUNK_SIZE + 1, 2), (dag.MAX_SIZE, 1), + (dag.MAX_SIZE, 10 ** 9)]: + self.assertEqual(range_bound(size, length), range_allowance(size, length)) + self.assertEqual(fetcher.FILE_NODE_MAX, FULL_FILE_NODE) + + def test_levels(self): + # 4 KiB granules: a file of up to 1 MiB has at most 256 chunks, so one node level; any size, at most 7 + self.assertEqual(file_levels(1), 1) + self.assertEqual(file_levels(MAX_CHILDREN * 4096 - 1), 1) + self.assertEqual(file_levels(MAX_CHILDREN * 4096), 2) + self.assertEqual(file_levels(dag.MAX_SIZE), 7) + + +class PaddedChainTest(TwoNodeTest): + """Reads of files whose chunks sit below chains of padded file nodes, refused within the bound.""" + + def serve_chained(self, pieces: list[bytes], filler_chunks: int = 0) -> tuple[Cid, int]: + """A file of pieces, each below LEVELS padded nodes, under a small root; (root, the root's block size). + filler_chunks adds, after them, an unpadded node of that many 4 KiB chunks, to declare a larger file.""" + blocks = Blocks() + children = [dag.Child(blocks.chain(blocks.raw(piece), len(piece), LEVELS), len(piece)) for piece in pieces] + if filler_chunks: + filler = blocks.raw(b"y" * 4096) + node = blocks.node(dag.FileNode([dag.Child(filler, 4096)] * filler_chunks)) + children.append(dag.Child(node, 4096 * filler_chunks)) + root = blocks.node(dag.FileNode(children)) + self.serve(blocks.blocks, root) + return root, len(blocks.blocks[root]) + + def assert_refused_within(self, read, root_bytes: int, size: int, length: int): + """read() raises TooLarge after B downloaded at most the root, the bound for the read and a block.""" + self.net.reset() + self.b.gc() + with self.assertRaises(TooLarge, msg="the read finished: nothing bounds it") as caught: + read() + self.assertIn("refusing it", str(caught.exception)) + allowed = root_bytes + range_allowance(size, length) + MAX_BLOCK_SIZE + self.assertLessEqual(self.net.downloaded_bytes, allowed, + f"{self.net.downloaded_bytes} bytes downloaded in {self.net.downloaded_blocks} blocks") + + def test_cat_of_a_1_byte_file(self): + root, root_bytes = self.serve_chained([b"x"]) + self.assertEqual(self.b.info(str(root))["size"], 1) + self.assert_refused_within(lambda: b"".join(self.b.read(str(root))), root_bytes, 1, 1) + + def test_gateway_and_offset_reads(self): + pieces = [bytes([i]) * 4096 for i in range(4)] + root, root_bytes = self.serve_chained(pieces) + size = 4 * 4096 + for offset, length in [(0, None), (5000, 1), (4095, 2), (3 * 4096, 4096)]: + wanted = size - offset if length is None else length + def read(o=offset, n=length): + """A gateway read of the range.""" + return b"".join(self.b.read(str(root), o, n, for_gateway=True)) + self.assert_refused_within(read, root_bytes, size, wanted) + + def test_sealed_info_and_get(self): + identity = RNS.Identity() + self.b.identity = identity + sealed = seal.seal_bytes(b"secret" * 2000, [identity.get_public_key()], "f.txt") + pieces = [sealed[i:i + 4096] for i in range(0, len(sealed), 4096)] + root, root_bytes = self.serve_chained(pieces) + # Its header is read first, through the first piece's chain: refused within that small read's bound + self.assert_refused_within(lambda: self.b.info(str(root)), root_bytes, len(sealed), len(seal.MAGIC)) + out = os.path.join(self.tmp.name, "sealed-out") + self.assert_refused_within(lambda: self.b.get(str(root), out), root_bytes, len(sealed), len(seal.MAGIC)) + self.assertFalse(os.path.lexists(out)) + + def test_a_large_declared_size_buys_a_few_levels_only(self): + # A root declaring 2.4 MB: an honest file that size has two node levels, so the bound for reading its + # first byte allows four full file nodes, still far below the chain's 2.4 MB + root, root_bytes = self.serve_chained([b"x"], filler_chunks=600) + size = self.b.info(str(root), raw=True)["size"] # without raw, info reads the start to look for a seal + self.assert_refused_within(lambda: b"".join(self.b.read(str(root), 0, 1)), root_bytes, size, 1) + + +class HonestRangeTest(TwoNodeTest): + """Honest files read at many offsets: the same blocks as without the bound, well inside it.""" + + def setUp(self): + """Count /blocks requests too.""" + super().setUp() + self.requests = 0 + real = self.net.blocks + + def counting(*args, **kwargs): + """NetClient.blocks, counted.""" + self.requests += 1 + return real(*args, **kwargs) + + self.net.blocks = counting + self.random = random.Random(2) + + def add(self, data: bytes, chunker: str | None = None) -> Cid: + """data added on A and advertised.""" + cid = Cid.parse(self.a.add_stream(io.BytesIO(data), chunker=chunker)["cid"]) + self.a.records.store(cid.key, A_ID) + return cid + + def read(self, node, cid: Cid, offset: int, length: int | None) -> tuple[bytes, int, int, int]: + """(bytes read, file node bytes downloaded, blocks downloaded, requests) for one read by node, from an + empty store.""" + node.gc() + self.net.reset() + self.requests = 0 + with node.fetcher.open(cid) as fetch: + data = b"".join(fetch.read_range(fetch.resolve(), offset, length)) + node_bytes = fetch.node_bytes + return data, node_bytes, self.net.downloaded_blocks, self.requests + + def assert_reads(self, cid: Cid, data: bytes, ranges: list[tuple[int, int | None]]): + """Each range reads right, within half its bound, downloading what an unbounded fetch downloads.""" + unbounded = self.make_node("c", bytes([0xC] * 16)) + unbounded.routing.heard(A_ID) + for offset, length in ranges: + end = len(data) if length is None else min(len(data), offset + length) + got, node_bytes, blocks, requests = self.read(self.b, cid, offset, length) + self.assertEqual(got, data[offset:end]) + allowed = range_allowance(len(data), end - offset) + self.assertLessEqual(node_bytes, allowed / 2, f"range {offset}+{length}: {node_bytes} of {allowed}") + with mock.patch.object(fetcher, "range_bound", lambda size, length: 1 << 62, create=True): + _, free_bytes, free_blocks, free_requests = self.read(unbounded, cid, offset, length) + self.assertEqual((node_bytes, blocks), (free_bytes, free_blocks)) + # A small range ending in two file nodes of a level asks for them one at a time: the bound leaves + # room for one MAX_BLOCK_SIZE node, not two + self.assertLessEqual(requests, free_requests + 1, f"range {offset}+{length}") + + def test_file_of_two_node_levels(self): + data = self.random.randbytes((MAX_CHILDREN + 3) * CHUNK_SIZE + 7) + boundary = MAX_CHILDREN * CHUNK_SIZE # where the root's first child ends + self.assert_reads(self.add(data), data, [ + (0, 1), (boundary - 1, 2), (boundary - 3, CHUNK_SIZE + 6), (CHUNK_SIZE - 1, 2), (len(data) - 1, None), + (len(data) - 10, 10), (12345, 3 * CHUNK_SIZE), (boundary // 2, None), (0, None)]) + + def test_fastcdc_file(self): + data = self.random.randbytes(3 * 1024 * 1024 + 7) + self.assert_reads(self.add(data, chunker="fastcdc"), data, [ + (0, 1), (17_000, 100_000), (1_000_000, 1), (len(data) - 1, 1), (0, None), (2 * 1024 * 1024, None)]) + + def test_media_seeks_through_the_gateway(self): + data = self.random.randbytes(40 * CHUNK_SIZE + 1) + cid = self.add(data) + for offset, length in [(0, 2), (CHUNK_SIZE * 7 + 3, 1 << 20), (len(data) - 2, None), (CHUNK_SIZE, 0)]: + self.b.gc() + got = b"".join(self.b.read(str(cid), offset, length, for_gateway=True)) + self.assertEqual(got, data[offset:None if length is None else offset + length]) + + def test_sealed_files(self): + identity = RNS.Identity() + self.b.identity = identity + for size, chunker in [(5, None), (3 * CHUNK_SIZE + 11, None), (2 * 1024 * 1024 + 3, "fastcdc")]: + plain = self.random.randbytes(size) + sealed = seal.seal_bytes(plain, [identity.get_public_key()], "f.bin") + cid = self.add(sealed, chunker) + self.b.gc() + info = self.b.info(str(cid)) + self.assertEqual((info["sealed"], info["size"]), (True, size)) + out = os.path.join(self.tmp.name, f"sealed-{size}") + self.assertTrue(self.b.get(str(cid), out)["sealed"]) + with open(out, "rb") as f: + self.assertEqual(f.read(), plain) + self.b.gc() + for offset, length in [(0, 1), (size // 2, 70_000), (max(0, size - 3), None)]: + got = b"".join(self.b.read(str(cid), offset, length)) + self.assertEqual(got, plain[offset:None if length is None else offset + length]) + + +if __name__ == "__main__": + unittest.main() From 4bdd14a63f6a90d2eb982d79b5fe45e05d16d047 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:23:22 +1030 Subject: [PATCH 083/107] Measure test_dag_caps' honest trees against the credit directory and index nodes now bring --- tests/security/test_dag_caps.py | 47 +++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/tests/security/test_dag_caps.py b/tests/security/test_dag_caps.py index 573c218..8ca5ec7 100644 --- a/tests/security/test_dag_caps.py +++ b/tests/security/test_dag_caps.py @@ -1,5 +1,6 @@ """DAG byte bound (fix for L3, N1 and L5): fetch_dag bounds the bytes of a DAG's distinct blocks by what its declared -size and the directory entries met can hold (rnfs.fetcher.dag_bound plus DAG_ENTRY_OVERHEAD per entry). +size can hold (rnfs.fetcher.dag_bound) plus what its directory and index nodes bring (rnfs.fetcher.node_credit: their +own bytes and DAG_ENTRY_OVERHEAD per entry or index child, less INDEX_NODE_COST for an index node). The tests check the bound's formula; that everything the builder writes (a file of hundreds of chunks over two node levels, FastCDC chunks, deep and wide directories with the longest names, empty files and directories) is fetched @@ -19,7 +20,8 @@ from rnfs.content.cid import DAG, EMPTY_FILE, RAW, Cid from rnfs.content.storage import USER from rnfs.errors import TooLarge -from rnfs.fetcher import DAG_ENTRY_OVERHEAD, DAG_GRANULE, DAG_GRANULE_OVERHEAD, DAG_SLACK, dag_bound +from rnfs.fetcher import (DAG_ENTRY_OVERHEAD, DAG_GRANULE, DAG_GRANULE_OVERHEAD, DAG_SLACK, INDEX_NODE_COST, dag_bound, + node_credit) from tests.security._content_helpers import TwoNodeTest from tests.test_node import A_ID @@ -50,10 +52,16 @@ def test_room_for_the_largest_honest_nodes(self): full = len(dag.encode(dag.FileNode([dag.Child(Cid.for_block(DAG, b"%d" % i), dag.MAX_SIZE) for i in range(MAX_CHILDREN)]))) self.assertLess(full, MAX_CHILDREN * (16 * 1024 // DAG_GRANULE) * DAG_GRANULE_OVERHEAD / 4) - # A directory entry with the longest name, and an index child, take well under DAG_ENTRY_OVERHEAD - entry = len(dag.encode(dag.DirectoryNode([dag.Entry(name(0), Cid.for_block(DAG, b"x"), dag.MAX_SIZE, 2)]))) - index = len(dag.encode(dag.IndexNode([dag.IndexChild(name(0), Cid.for_block(DAG, b"x"), dag.MAX_SIZE)]))) - self.assertLess(max(entry, index), DAG_ENTRY_OVERHEAD // 3) + # A directory node pays for itself; so does an index node of a few children, but one of a single child + # costs a little, so a chain of them can't pay for itself + index = dag.IndexNode([dag.IndexChild(name(0), Cid.for_block(DAG, b"x"), dag.MAX_SIZE)]) + expect = walker.Expect(Cid.for_block(DAG, dag.encode(index)), dag.MAX_SIZE, walker.DIRECTORY_TYPES, 1) + block = dag.encode(index) + self.assertLess(node_credit(len(block), walker.expand(expect, block)), len(block)) + self.assertGreater(INDEX_NODE_COST, 3 * DAG_ENTRY_OVERHEAD) + # An honest index tree has at most one node that costs anything per level, met before the entries below + # it bring their share: seven levels (2^64 entries) fit DAG_SLACK + self.assertLess(7 * (INDEX_NODE_COST - DAG_ENTRY_OVERHEAD), DAG_SLACK) class CapTest(TwoNodeTest): @@ -91,30 +99,35 @@ def add_tree(self, make) -> Cid: self.a.records.store(cid.key, A_ID) return cid - def usage(self, node, cid: Cid) -> tuple[int, int, int]: - """(declared size, bytes of distinct blocks, entries and index children counted) of a stored DAG.""" + def usage(self, node, cid: Cid) -> tuple[int, int, int, int]: + """(declared size, bytes of distinct blocks, what its nodes bring to the bound, directory entries) of a + stored DAG.""" get = node.storage.getter() expect = walker.root_expect(cid) - total = entries = 0 + total = credit = entries = 0 for current, block in walker.iter_dag(expect, get): total += len(block) if not current.cid.is_raw: - entries += sum(1 for child in walker.expand(current, block) - if child.kind is not None or child.first_name is not None) - return walker.size_of(expect, get(expect)), total, entries + children = walker.expand(current, block) + credit += node_credit(len(block), children) + entries += sum(1 for child in children if child.kind is not None) + return walker.size_of(expect, get(expect)), total, credit, entries def assert_well_inside(self, cid: Cid): - """B fetched cid's DAG with get, pin add and export; its overhead takes under half what the bound allows.""" + """B fetched cid's DAG with get, pin add and export; of the bound's margin (what neither the content nor + nodes paying for themselves take: dag_bound beyond the size, and DAG_ENTRY_OVERHEAD per entry), its + nodes take under half.""" out = os.path.join(self.tmp.name, f"out{self.random.random()}") self.b.pin_add(str(cid)) self.assertIn(USER, self.b.storage.pins()[cid]) self.assertTrue(self.b.storage.is_complete(cid)) self.b.get(str(cid), out) self.b.export(str(cid), out + ".archive") - size, total, entries = self.usage(self.b, cid) - allowed = dag_bound(size) + DAG_ENTRY_OVERHEAD * min(entries, self.b.config.max_output_entries) - self.assertLessEqual(total - size, (allowed - size) / 2, - f"{total - size} bytes of nodes for {size} bytes and {entries} entries") + size, total, credit, entries = self.usage(self.b, cid) + allowed = dag_bound(size) + credit + margin = dag_bound(size) - size + DAG_ENTRY_OVERHEAD * entries + self.assertGreaterEqual(allowed - total, margin / 2, + f"{total} bytes of blocks for {size} bytes and {entries} entries: {allowed} allowed") return out # Honest content From 383f770a1615e7f7fa6e1b14569381a4954c44d0 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:23:22 +1030 Subject: [PATCH 084/107] Refuse a get or export whose destination's free space can't be read --- rnfs/node.py | 8 ++- tests/security/test_l9_free_space_unknown.py | 56 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/security/test_l9_free_space_unknown.py diff --git a/rnfs/node.py b/rnfs/node.py index 12326a8..19966e1 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -99,14 +99,18 @@ def _check_free_space(path: str, needed: int, blocks_dir: str | None = None, to_ If blocks_dir (where a fetch stores its blocks) is on the same filesystem, the to_fetch bytes still to be fetched there before the write count too: the fetch keeps the reserve, and the write-out must as well. + Raises OSError if the free space can't be read (a missing directory, say): unchecked, the write could fill + the disk. """ directory = os.path.dirname(os.path.abspath(path)) try: usage = shutil.disk_usage(directory) if blocks_dir is not None and to_fetch > 0 and os.stat(directory).st_dev == os.stat(blocks_dir).st_dev: needed += to_fetch - except OSError: - return # a missing or unusable directory is reported by the write itself + except OSError as e: + # The same kind of error (FileNotFoundError, PermissionError...), saying what couldn't be done + message = f"can't check the free space for {directory}: {e.strerror or e}" + raise (OSError(e.errno, message) if e.errno is not None else OSError(message)) from None reserve = disk_reserve(usage.total) if needed > usage.free - reserve: raise TooLarge(f"{size(needed)} won't fit in the {size(usage.free)} free in {directory} " diff --git a/tests/security/test_l9_free_space_unknown.py b/tests/security/test_l9_free_space_unknown.py new file mode 100644 index 0000000..60ce49d --- /dev/null +++ b/tests/security/test_l9_free_space_unknown.py @@ -0,0 +1,56 @@ +"""L9 residual: node._check_free_space returned without a word when it couldn't read the destination's free space +(OSError from disk_usage or stat), so `get` and a sealed write-out went ahead with no check at all: it failed open. + +An offline node holding a small file gets it while disk_usage, by a mock, raises PermissionError: the get must be +refused, saying it couldn't check the free space, and leave no output, where it used to write the file. +""" +import io +import os +import shutil +import tempfile +import unittest +from unittest import mock + +import RNS + +from rnfs.config import Config +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_node import A_ID, NetClient + + +class FreeSpaceUnknownTest(unittest.TestCase): + """An offline node with a stored file, whose destination filesystem can't be queried.""" + + def setUp(self): + """The node and its stored file.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + config = Config(rnfs_dir=os.path.join(self.tmp.name, "a"), slow="no", lookup_timeout=1, path_timeout=0.2) + self.node = RnfsNode.offline(config, A_ID, NetClient(), FakeNetwork()) + self.addCleanup(self.node.stop) + self.cid = self.node.add_stream(io.BytesIO(b"hello"))["cid"] + self.out = os.path.join(self.tmp.name, "out") + + def test_get_is_refused(self): + real = shutil.disk_usage + + def unreadable(path): + """disk_usage, failing for the destination's directory.""" + if os.path.abspath(path) == self.tmp.name: + raise PermissionError(13, "Permission denied") + return real(path) + + with mock.patch("shutil.disk_usage", unreadable): + with self.assertRaises(OSError, msg="get went ahead without checking the free space") as caught: + self.node.get(self.cid, self.out) + self.assertIn("free space", str(caught.exception)) + self.assertFalse(os.path.lexists(self.out)) + self.assertEqual(os.listdir(self.tmp.name), ["a"]) # no partial output either + + +if __name__ == "__main__": + unittest.main() From bb23b64351315b62d33259c778f52d5f3f3b75b1 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:23:22 +1030 Subject: [PATCH 085/107] Accept only 32 hex digits as a node ID or name in the local API and gateway --- rnfs/api.py | 12 +++---- .../test_t1_parse_hex_id_whitespace.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 tests/security/test_t1_parse_hex_id_whitespace.py diff --git a/rnfs/api.py b/rnfs/api.py index ec68cea..c54bf1d 100644 --- a/rnfs/api.py +++ b/rnfs/api.py @@ -74,6 +74,7 @@ from rnfs import __version__, fsutil from rnfs import node_content # --- content: add options (pin, to, chunker) --- from rnfs.cli import CHALLENGE_HEADER, PROOF_HEADER, api_proof +from rnfs.config import _HEX_ID # exactly 32 hex digits, as names.parse_name and identity lists are checked from rnfs.content.cid import Cid from rnfs.errors import Busy, InvalidDag, NotFound, StorageFull, TooLarge from rnfs.log import LOG_ERROR, log @@ -148,14 +149,11 @@ def parse_cid(text) -> str: def parse_hex_id(text, what: str = "node ID") -> bytes: - """A 16-byte node ID or name written as 32 hex characters. Raises ValueError.""" - try: - value = bytes.fromhex(text) if isinstance(text, str) else b"" - except ValueError: - value = b"" - if len(value) != 16: + """A 16-byte node ID or name written as 32 hex characters (spaces around allowed). Raises ValueError.""" + # Hex digits only: bytes.fromhex alone would skip whitespace between them (as names.parse_name checks) + if not isinstance(text, str) or not _HEX_ID.fullmatch(text.strip()): raise ValueError(f"invalid {what} {text!r}: must be 32 hex characters") - return value + return bytes.fromhex(text.strip()) def parse_path(text) -> str: diff --git a/tests/security/test_t1_parse_hex_id_whitespace.py b/tests/security/test_t1_parse_hex_id_whitespace.py new file mode 100644 index 0000000..55a3e49 --- /dev/null +++ b/tests/security/test_t1_parse_hex_id_whitespace.py @@ -0,0 +1,36 @@ +"""T1 residual: api.parse_hex_id (node IDs in /api/info, /api/ping, /api/get, /api/pin/remote, jobs, and names in +gateway URLs) decoded with bytes.fromhex, which skips whitespace between byte pairs, so text that isn't 32 hex +digits passed as long as it held 32 of them. names.parse_name was made strict for the same reason. + +The test gives parse_hex_id such texts and asserts a ValueError, and checks the plain forms still parse. +""" +import unittest + +from rnfs.api import parse_hex_id + + +class ParseHexIdWhitespaceTest(unittest.TestCase): + """parse_hex_id must take exactly 32 hex digits (surrounding whitespace aside).""" + + def test_inner_whitespace_is_rejected(self): + texts = ["0011 2233 4455 6677 8899 aabb ccdd eeff", "00112233445566 77 8899aabbccddeeff", + "00112233445566\t77\n8899aabbccddeeff"] + accepted = {} + for text in texts: + try: + accepted[text] = parse_hex_id(text).hex() + except ValueError: + pass + self.assertEqual(accepted, {}, "parse_hex_id accepted text that isn't 32 hex digits") + + def test_plain_forms_still_parse(self): + value = bytes(range(16)) + for text in (value.hex(), value.hex().upper(), f" {value.hex()}\n"): + self.assertEqual(parse_hex_id(text), value) + for text in ("", value.hex()[:-1], value.hex() + "0", "zz" * 16, None, 5): + with self.assertRaises(ValueError): + parse_hex_id(text) + + +if __name__ == "__main__": + unittest.main() From a9cf534d533baf9fddcc47f31e9c936ebd24227c Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:23:22 +1030 Subject: [PATCH 086/107] Document the byte-range and listing bounds, the per-entry credit, and the free space check failing closed --- CHANGELOG.md | 24 +++++++++++++---- design.md | 69 +++++++++++++++++++++++++++++++++++------------- specification.md | 44 +++++++++++++++++++----------- 3 files changed, 98 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f39555..7fa21be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,30 @@ Fixes: - A fetch of a whole DAG (`get`, `pin add`, `export`, `dir set`/`dir rm`, a followed name's new target, gateway archives, remote pins and LXMF auto-fetches) is refused with "too large" once the DAG's blocks hold more than - its declared size can: the size plus 64 bytes per 4 KiB, 1 KiB, and 1 KiB per directory entry met. A file - node may list empty children, so a file declaring 1 byte could make `get` or `pin add` download and keep any - amount of data. Content built by `add` (either chunker) stays far inside the bound, and fetches of it ask - for blocks as before. Requests under the bound never ask for more than it leaves, so a refused fetch + its declared size can: the size plus 64 bytes per 4 KiB, 1 KiB, and what its directory nodes take plus 16 + bytes per entry (index nodes: their own bytes plus 16 per child, less 64). A file node may list empty + children, so a file declaring 1 byte could make `get` or `pin add` download and keep any amount of data. + Content built by `add` (either chunker) stays far inside the bound, and fetches of it ask for blocks as + before; there is no limit on the entries of a directory you pin (`get` and `export` still stop at + `max_output_entries`). Requests under the bound never ask for more than it leaves, so a refused fetch downloads at most about one block too many. +- A byte-range read (`cat`, the gateway's file responses and Range requests, `/api/cat`, and reading or + decrypting a sealed file, as `get`, `cat` and `info` do for one sealed to this node) is refused with "too + large" once the file nodes it meets hold more than the range can need: its own bound as above plus two full + file nodes (13 KB each) per level a file of the declared size can have (one up to 1 MiB, seven at most). + These reads never ran the whole-DAG bound, so `cat` of a file declaring 1 byte could download 7 MB, and + `get` of a 12 KB file sealed to you 29 MB; now at most about 184 KB plus one block. A small read that ends + inside two file nodes may take one more request than before. +- `ls` and the gateway's directory listings are bounded the same way, as a fetch of 0 bytes: chains of index + nodes of one child each used to be listed level by level without limit (751 KB for 40 entries). +- `get` and `export` refuse to write when they can't read the free space at the destination (a missing directory, say), + instead of writing without the disk reserve check. - `get` and `export` refuse output that would leave less than the disk reserve (max(256 MiB, 2%)) free, and `get` counts the blocks it still has to fetch when they go to the same disk. They used to check only that the output fits, so a `get` could fill the disk. - Names given as text must be exactly 32 hex digits (optionally in ``, with spaces around): - whitespace between the digits used to be skipped, giving a name shorter than 16 bytes. + whitespace between the digits used to be skipped, giving a name shorter than 16 bytes. Node IDs and names + given to the local API and the gateway are checked the same way (spaces around allowed, not between). - A remote `/pin` accepted just as the previous one of that identity finished was answered "accepted" and never fetched (sending it again didn't help): the fetch thread now deregisters in the same step that finds nothing left. If the thread can't be started the `/pin` is refused, where the identity used to be stuck diff --git a/design.md b/design.md index a961cf4..f93e0e5 100644 --- a/design.md +++ b/design.md @@ -530,28 +530,58 @@ Everything in the specification is implemented, with these simplifications and a - **DAG byte bound** (`fetcher.dag_bound`, `Fetch.dag_limit`). `fetch_dag` counts the bytes of the distinct blocks of its target's DAG, stored or downloaded, against `dag_bound(size)` = size + `DAG_GRANULE_OVERHEAD` (64) per `DAG_GRANULE` (4 KiB, `SmallLeaves`' threshold) of the declared size + `DAG_SLACK` (1 KiB), plus - `DAG_ENTRY_OVERHEAD` (1 KiB) per directory entry or index child of the counted nodes (at most - `max_output_entries` of them), and `max_dag_bytes` (a remote pin's or auto-fetch's quota) if lower; over it - is `TooLarge`, not `InvalidDag`. Every caller gets it, since it lives in `fetch_dag`: `get`, `pin_add`, + what the counted directory and index nodes bring (`node_credit`): a directory node its own bytes and + `DAG_ENTRY_OVERHEAD` (16) per entry, an index node its own bytes and 16 per child less `INDEX_NODE_COST` + (64), never below 0; and `max_dag_bytes` (a remote pin's or auto-fetch's quota) if lower. Over it is + `TooLarge`, not `InvalidDag`. Every caller gets it, since it lives in `fetch_dag`: `get`, `pin_add`, `dir_set`/`dir_rm`, `export`, `export_stream`, follow, remote pins and LXMF auto-fetches. Why the builder's output never comes near it: a file node child is at most 51 bytes (`[cid, size]`: 39-byte CID, 9-byte size) and a node header at most 7; fixed chunks are 64 KiB and FastCDC chunks over 16 KiB except a file's last, so with the levels above (at most n/255 + 8 nodes for n chunks) a file takes under 13 bytes per 4 KiB plus - under 600 bytes (its last chunk and up to 8 levels). Those 600 bytes come out of `DAG_SLACK` for the target - itself, or out of its entry's 1 KiB, which also covers the entry (at most 309 bytes with a 255-byte name), - the header of the node it points to and its share of the index nodes above. An empty file is the 0-byte raw - block and an empty directory 3 bytes, the same block each time. Counting is incremental and the allowance - only grows: a node's entries are credited when its block is counted, before anything below it, and the - chunks are counted against the declared size, so the bound holds at every block, not only at the end - (tests/security/test_dag_caps.py fetches large, FastCDC, deep, wide and empty trees, using under a third - of what it allows). What it refuses: zero-size children (audit L3), and DAGs of chunks well under 4 KiB, - which no writer following the tree rule makes. To keep the overshoot to about one block, `_take_batch` - asks only for what fits the room left after the requests in flight (`_room`, `_reserve`): a raw chunk's - declared size, anything else `MAX_BLOCK_SIZE`, except that under the size-derived bound (not under a - quota, which entries don't raise) a directory or index node takes only its header, since its entries bring - more room than they take while enough are left under `max_output_entries`; and always one CID when nothing - is in flight. Honest fetches are not held back by this: the content still to come leaves - room for many file nodes, and directory nodes cost almost nothing. + under 600 bytes (its last chunk and up to 8 levels). A file with a node has two chunks or more, the first + at least 16 KiB, so its 64 bytes per 4 KiB (at least 256 per chunk but its last) cover both, inside a + directory as for the target itself. A directory node pays for itself, so an entry costs nothing whatever + its name. An index node is met before the entries below it, which is why its children bring their 16 bytes + in advance: one of 4 children or more pays for itself, and the tree rule fills every index node but the + last of its level, so an honest directory meets at most 48 bytes of index nodes per level (at most 7 levels) + before its entries arrive, inside `DAG_SLACK`, and about one index node per 65,536 entries after that. An + empty file is the 0-byte raw block and an empty directory 3 bytes, the same block each time. Counting is + incremental: a node's credit is added when its block is counted, before anything below it, and the chunks + are counted against the declared size, so the bound holds at every block, not only at the end + (tests/security/test_dag_caps.py fetches large, FastCDC, deep, wide and empty trees: they use at most 8% + of the bound's margin, the part neither the content nor nodes paying for themselves take). What it + refuses: zero-size children (audit L3), chains of index nodes (48 bytes a level), duplicate entries + carrying other blocks (each brings 16 bytes beyond its own: a 0-byte directory carries at most that much + of other nodes, where 1 KiB per entry used to let it carry 20 times its own bytes), and DAGs of chunks well + under 4 KiB, which no writer following the tree rule makes. The credit isn't capped at + `max_output_entries` entries, so a pin of an honest directory of any size is allowed (`get` and `export` + still stop at `max_output_entries` entries); a directory's blocks are then about what an honest directory + of as many entries takes, and `max_download_size` and quotas bound them. To keep the overshoot to about + one block, `_take_batch` asks only for what fits the room left after the requests in flight (`_room`, + `_reserve`): a raw chunk's declared size, anything else `MAX_BLOCK_SIZE`, except that under the + size-derived bound (not under a quota, which entries don't raise) a directory or index node takes nothing, + since it costs at most 64 bytes (a request of `MAX_BATCH` of them overshoots by at most 2 KiB); and always + one CID when nothing is in flight. Honest fetches are not held back by this: the content still to come + leaves room for many file nodes, and directory nodes cost nothing. +- **Listings** (`fetch_listing`: `ls`, the gateway's directory pages) fetch only directory and index nodes, + and are bounded as `fetch_dag` is for a declared size of 0 (`_declared` = 0): `DAG_SLACK` plus the nodes' + credit. It used to count index children per level only, so 40 entries below chains of single-child index + nodes down to `MAX_DEPTH` took 751 KB; now a single-child index node costs 48 bytes, and those chains are + refused after 24 KB. +- **Byte-range reads** (`read_range`: `cat`, the gateway's file responses, `/api/cat`, a sealed file's + header and its decryption by `get`, `cat` and `info`, which never run `fetch_dag`) count the bytes of the + distinct file nodes each call meets, stored or downloaded, against `range_bound(size, length)` = + `dag_bound(length)` + 2 × `file_levels(size)` × `FILE_NODE_MAX` (13,061 bytes: a header of 5 and 256 + children of 51). Nodes wholly inside the range cost no more than in a whole file of `length` bytes; on each + level the range ends inside at most two more; and an honest file of `size` bytes, cut into chunks of 4 KiB + or more, has `file_levels(size)` levels: enough 256-wide levels for size / 4 KiB + 1 chunks, one up to + 1 MiB, two up to 256 MiB, seven for any size. Raw chunks aren't counted: they hold the range and at most + two chunks more. A file node padded with empty children made `cat` of a file declaring 1 byte download + 7.3 MB (and a sealed `get` of 12 KB 29 MB); now the read stops at about 27 KB plus one block, and at most + about 184 KB plus one block for any declared size. The budget (`_RangeBudget`) is set only while + `_range_leaves` ensures a node's children, since another read of the same fetch may run between a + generator's steps; `_reserve` counts a file node not yet received as `MAX_BLOCK_SIZE` against it, so a + small honest range ending inside two nodes of a level asks for them in two requests instead of one. - `max_storage` is soft. An unpinned fetch evicts other cached fetches (LRU) to make room for each response and goes above `max_storage` when nothing is evictable; when its session ends the node evicts again, which may evict what it fetched if pins alone fill `max_storage`. Any fetch is refused with `StorageFull` ("not @@ -560,7 +590,8 @@ Everything in the specification is implemented, with these simplifications and a `get` and `export` also check free disk space at the destination first (`node._check_free_space`): the output must leave the same reserve free there, and when the destination is on the blocks' filesystem, `get` (and a sealed file's write-out) counts what it still has to fetch too (the verified size less the bytes of - the target's DAG already stored). + the target's DAG already stored). If the destination's free space can't be read, the check raises the + `OSError` (same errno, saying so) instead of letting the write go ahead unchecked. - Roots found invalid are remembered in `state/invalid.json` (at most 10 000, oldest dropped), and there is no command to clear them. Cache records are capped at 10 000 (LRU). Both are saved every 30 s and at stop. - A remote pin's usage is the larger of its content size and its DAG's block bytes. It is dropped once the diff --git a/specification.md b/specification.md index 72d1962..dd9297f 100644 --- a/specification.md +++ b/specification.md @@ -617,25 +617,38 @@ and repeat. 5. Fetching a target's whole DAG (`get`, `pin add`, `export`, `dir set` and `dir rm`, a followed name's new target, the gateway's archives, remote pins and LXMF auto-fetches) is bounded by what its verified size can hold. The bytes of its distinct blocks, stored or downloaded, may not exceed the size, plus 64 bytes per - 4 KiB of it (rounded up), plus 1 KiB, plus 1 KiB per directory entry or index child met (at most - `max_output_entries` of them); a remote pin's or auto-fetch's quota applies too, whichever is lower. A file - node may list children of size 0, so without this a file that declares 1 byte could hold any number of - distinct nodes. Every DAG built by the tree rule (section 5) stays far inside the bound: its file nodes take - under 13 bytes per 4 KiB of content, and each entry, with the nodes above it and the levels of a file - below it, under 1 KiB. A fetch that would exceed it fails ("too large") without marking the root invalid. - While the bound applies, a requester asks only for blocks that fit the room it leaves, counting a DAG node - not yet received as `MAX_BLOCK_SIZE` (under the size-derived bound, a directory or index node as its header - only, since its entries bring more room than they take; under a quota, in full), and always for at least - one block when none is in flight, so a fetch downloads at most about one block beyond either. Byte-range reads are lazy and skip empty children instead. -6. Spread requests over up to `max_parallel_providers` providers (1 on slow nodes, where parallel transfers + 4 KiB of it (rounded up), plus 1 KiB, plus what its directory and index nodes bring: a directory node its + own bytes and 16 bytes per entry, an index node its own bytes and 16 bytes per child less 64. A remote pin's + or auto-fetch's quota applies too, whichever is lower. A file node may list children of size 0, and index + nodes of one child can be chained, so without this a file that declares 1 byte, or a directory of empty + files, could hold any number of distinct nodes. Every DAG built by the tree rule (section 5) stays far + inside the bound: its file nodes take under 13 bytes per 4 KiB of content plus under 600 bytes, which the + 64 bytes per 4 KiB cover for any file of more than one chunk; its index nodes cost under 2 bytes per entry, + and at most 48 bytes a level before the entries below them arrive. A fetch that would exceed it + fails ("too large") without marking the root invalid. While the bound applies, a requester asks only for + blocks that fit the room it leaves, counting a DAG node not yet received as `MAX_BLOCK_SIZE` (under the + size-derived bound, a directory or index node as nothing, since it costs at most 64 bytes; under a quota, + in full), and always for at least one block when none is in flight, so a fetch downloads at most about one + block beyond either. Listing a directory (`ls`, the gateway's listings) fetches only its directory and + index nodes, under the same bound for a size of 0. +6. A byte-range read (`cat`, the gateway's file responses and Range requests, a sealed file's header and its + decryption by `get`, `cat` and `info`) is lazy: it fetches only the chunks covering the range and the file + nodes above them, and skips empty children. The file nodes it meets, stored or downloaded, may not exceed + what the range's size can hold (as in item 5, for the range's size), plus two file nodes of 256 children + (13,061 bytes) for each level a file of the target's verified size can have under the tree rule with + chunks of 4 KiB or more (one level up to 1 MiB, seven for any size): a range ends inside at most two nodes + of a level. Otherwise it fails ("too large"); a requester counts a file node not yet received as + `MAX_BLOCK_SIZE`, as in item 5, so a small range ending in two nodes of a level asks for them one at a + time. +7. Spread requests over up to `max_parallel_providers` providers (1 on slow nodes, where parallel transfers only contend for the same channel). A response *stalls* if it makes no progress for `stall_timeout`, or misses the same deadline the responder uses (section 3). A stalled provider's link is closed, which cancels the transfer, and its blocks are requested again from another provider; providers that keep failing are handled as in section 6. A fetch that fails or is cancelled closes the links of its requests still in flight, so their responses don't keep using the link and hold up the next `/blocks` to those nodes. -7. Blocks are stored as they arrive, so an interrupted fetch resumes where it stopped. An RNS Resource is only +8. Blocks are stored as they arrive, so an interrupted fetch resumes where it stopped. An RNS Resource is only delivered when complete, so a response lost mid-transfer yields nothing and its blocks are requested again. -8. When the target is complete, the node provides it if its strategy says so (section 6). +9. When the target is complete, the node provides it if its strategy says so (section 6). ## 8. Storage @@ -680,7 +693,8 @@ and repeat. max(256 MiB, 2% of the filesystem) free on the filesystem holding `/blocks/`. - `get` and `export` write their output only if it leaves the same reserve (of the output's filesystem) free. When that is the filesystem holding the blocks, what `get` still has to fetch counts too: its verified size - less the bytes of the target's DAG already stored. + less the bytes of the target's DAG already stored. If the free space there can't be read (the directory is + missing, say), they fail without writing. **Shared folder.** Every `scan_interval`, files and directories in `/files/` are imported as with `add` (copied into the block store) and pinned with source `folder`. A changed or removed file releases its @@ -856,7 +870,7 @@ as is cheap. Rules live in the sections referenced. | Threat | Protection | |---|---| | Forged or corrupted data | Every block verified against its CID (7) | -| Size lies, repeated or empty-subtree bombs, deep trees | Verified sizes, `max_download_size`, `max_output_entries`, the DAG byte bound of whole-DAG fetches, `MAX_DEPTH`, each block fetched once (5, 7) | +| Size lies, repeated or empty-subtree bombs, deep trees | Verified sizes, `max_download_size`, `max_output_entries`, the DAG byte bounds of whole-DAG fetches, listings and byte-range reads, `MAX_DEPTH`, each block fetched once (5, 7) | | Malformed DAG nodes | Strict msgpack types, re-encoding and per-node checks (5). The tree rule gives honest writers one CID per content; readers don't enforce it | | Advertising on someone else's behalf | Records name only the identified requester (6) | | Advertising content it doesn't have, or stale records | Failed providers removed; the search runs again; withdrawals (6, 7) | From e2e78c253fce24e581aba53a1e3c26892a2497c6 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 087/107] Let test_m3_keeper_discovery's ranking stand-ins take the standing snapshot, and make C and D established for the one-batch test --- tests/security/test_m3_keeper_discovery.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/security/test_m3_keeper_discovery.py b/tests/security/test_m3_keeper_discovery.py index 9827923..a67643f 100644 --- a/tests/security/test_m3_keeper_discovery.py +++ b/tests/security/test_m3_keeper_discovery.py @@ -185,8 +185,10 @@ def test_slow_resolver_asks_at_most_two_keepers(self): self.assertLessEqual(len(self.net.name_requests), 2) def test_fast_resolver_asks_one_batch_when_two_keepers_answer(self): - """alpha 3: the first batch reaches A's replacement keepers C and D, which both answer; nothing more.""" + """alpha 3: the first batch reaches A's replacement keepers C and D, established servers in B's table, + which both answer; nothing more.""" self.build() + self.b.config.provider_seniority = 0 # every server in B's table is established self.keep_on_c() keeper = self.d.feature("keepers") keeper.keep(self.name) @@ -195,7 +197,8 @@ def test_fast_resolver_asks_one_batch_when_two_keepers_answer(self): self.d.publisher.publish_due() self.squat([self.b], SQUATTERS) with mock.patch.object(self.b.feature("keepers"), "rank_keepers", - lambda candidates: sorted(candidates, key=lambda p: (p not in (C_ID, D_ID), p))): + lambda candidates, before=None: sorted(candidates, + key=lambda p: (p not in (C_ID, D_ID), p))): result = self.resolve_without_owner() self.assertEqual(sorted(self.net.name_requests[:2]), [C_ID, D_ID]) self.assertEqual(len(self.net.name_requests), 3) # one batch of alpha @@ -221,7 +224,7 @@ def test_answer_older_than_known_asks_a_further_batch(self): cid2 = self.two_keepers_one_behind() order = [C_ID, D_ID] with mock.patch.object(self.b.feature("keepers"), "rank_keepers", - lambda candidates: sorted(candidates, key=lambda p: order.index(p) if p in order + lambda candidates, before=None: sorted(candidates, key=lambda p: order.index(p) if p in order else len(order))): result = self.resolve_without_owner() self.assertEqual(self.net.name_requests, [C_ID, D_ID]) @@ -233,7 +236,7 @@ def test_current_answer_on_slow_resolver_asks_one(self): self.two_keepers_one_behind() order = [D_ID, C_ID] with mock.patch.object(self.b.feature("keepers"), "rank_keepers", - lambda candidates: sorted(candidates, key=lambda p: order.index(p) if p in order + lambda candidates, before=None: sorted(candidates, key=lambda p: order.index(p) if p in order else len(order))): result = self.resolve_without_owner() self.assertEqual(self.net.name_requests, [D_ID]) From c83ceab7224d3da1f7533c04ed6f5a7aa02ff2aa Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 088/107] Rank keepers on the routing table as it stood before the search, end it only on established sources, draw batches from different sources, and give unknown keepers their own budget --- rnfs/dht/lookup.py | 13 ++ rnfs/dht/routing.py | 19 ++- rnfs/features/keepers.py | 119 +++++++++++++----- tests/security/_dhtr2b_helpers.py | 104 +++++++++++++++ tests/security/test_m3r1_keeper_standing.py | 87 +++++++++++++ .../test_m3r2_keeper_unknown_budget.py | 53 ++++++++ 6 files changed, 360 insertions(+), 35 deletions(-) create mode 100644 tests/security/_dhtr2b_helpers.py create mode 100644 tests/security/test_m3r1_keeper_standing.py create mode 100644 tests/security/test_m3r2_keeper_unknown_budget.py diff --git a/rnfs/dht/lookup.py b/rnfs/dht/lookup.py index f17ab6b..645561e 100644 --- a/rnfs/dht/lookup.py +++ b/rnfs/dht/lookup.py @@ -86,6 +86,7 @@ def __init__(self, key: bytes, start_nodes: Iterable[bytes], query: FindQuery, o self._refused_unknown: set[bytes] = set() # unknown IDs over the budget self._reported: set[bytes] = set() # providers already passed to on_provider, in any run self._listing: set[bytes] = set() # nodes whose reply listed a provider, in any run + self._listed_by: dict[bytes, set[bytes]] = {} # provider -> nodes whose reply listed it, in any run self._last_known: dict[bytes, int] = {} # the nodes the last run knew of and hadn't seen fail self._last_queried: set[bytes] = set() # the nodes the last run queried self._wake: queue.Queue | None = None @@ -152,6 +153,16 @@ def listing_count(self) -> int: with self._lock: return len(self._listing) + def listing_nodes(self) -> list[bytes]: + """The nodes that have answered with at least one provider (in any run).""" + with self._lock: + return list(self._listing) + + def listers(self, provider: bytes) -> set[bytes]: + """The nodes whose replies listed provider (in any run), so a caller can tell who vouches for it.""" + with self._lock: + return set(self._listed_by.get(provider, ())) + def closest_answered(self) -> list[bytes]: """The k closest nodes that answered in any run so far, closest first.""" with self._lock: @@ -247,6 +258,8 @@ def _iterate(self, results: queue.Queue, on_provider: NodeCallback | None, if providers: with self._lock: self._listing.add(node_id) + for provider in providers: # at most k per reply, from nodes that answered + self._listed_by.setdefault(provider, set()).add(node_id) for new_id in nodes: if new_id not in known and new_id not in queried and self._usable(new_id): known[new_id] = self._distance(new_id) diff --git a/rnfs/dht/routing.py b/rnfs/dht/routing.py index 4a85c10..bb2081b 100644 --- a/rnfs/dht/routing.py +++ b/rnfs/dht/routing.py @@ -196,12 +196,21 @@ def standing(self, node_id: bytes, min_age: float) -> int: or silent over 2 × announce_interval, 3 not in the table. Ranks keepers (section 9).""" with self._lock: entry = self._find(node_id) - if entry is None: - return 3 + return 3 if entry is None else self._standing(entry, min_age, self.clock()) + + def standings(self, min_age: float) -> dict[bytes, int]: + """standing() of every node in the table now (those left out stand at 3), so a caller can rank nodes + on the table as it was before a lookup: nodes the lookup adds by answering don't gain rank from it.""" + with self._lock: now = self.clock() - if self._stale(entry, now): - return 2 - return 0 if now - entry.first_seen >= min_age else 1 + return {entry.node_id: self._standing(entry, min_age, now) + for bucket in self._buckets for entry in bucket.values()} + + def _standing(self, entry: _Entry, min_age: float, now: float) -> int: + """standing() of a node in the table. Caller holds the lock.""" + if self._stale(entry, now): + return 2 + return 0 if now - entry.first_seen >= min_age else 1 def contains(self, node_id: bytes) -> bool: """True if node_id is in the table.""" diff --git a/rnfs/features/keepers.py b/rnfs/features/keepers.py index c73d34b..ad78a5e 100644 --- a/rnfs/features/keepers.py +++ b/rnfs/features/keepers.py @@ -15,11 +15,12 @@ a CID (batched /provide, republish, withdrawal); the prefix keeps it apart from CID keys (SHA-256 of a 36- or 39-byte binary CID). A name is advertised only once a record of it is held. - **Resolving** (NameManager.resolve): when the path request heard nothing (or always, with keeper_query = - "always"), find providers of the name's key (past the first alpha, with local records only candidates), rank - them by routing-table standing then at random, and ask them with /name a batch of alpha at a time, asking - another batch (up to a bound) while no answer reaches the sequence already known or only one keeper - answered; only verifying records are offered to the usual sequence rules (persisted highest sequence, no - rollback). + "always"), find providers of the name's key (past the first alpha, with local records only candidates, until + two established servers list some), rank them by routing-table standing as it was before the search, then at + random, and ask them with /name a batch of alpha at a time, each batch drawing first on sources no earlier + pick came from, asking another batch (up to a bound) while no answer reaches the sequence already known or + only one keeper (one established keeper, when no sequence is known) answered; only verifying records are + offered to the usual sequence rules (persisted highest sequence, no rollback). - **What is kept.** Names kept by the user (`rnfs name keep`), followed names (rnfs.features.follow), and this node's own published names, which it signs itself. At most keep_max_names, own names not counted. - **Refreshing.** On hearing an announce with a higher sequence for a kept name, the signed announce is read @@ -69,6 +70,7 @@ KEEPER_CANDIDATES = 3 # the search for a name's keepers runs until 3 × alpha are known (from 2+ sources) KEEPER_ROUNDS = 3 # batches of alpha keepers one query asks at most (2 on slow nodes) REQUEST_ID_LENGTH = 16 +_UNVOUCHED = b"" # ask_keepers: the source of candidates no established node (nor this one) listed def name_key(name: bytes) -> bytes: @@ -639,42 +641,94 @@ def ask_keepers(self, name: bytes) -> list[SignedRecord]: """Find keepers of name in the DHT and ask them with /name, a batch of alpha at a time; the verifying records. - Keepers recorded here are candidates, never a reason to skip the search, which runs until - KEEPER_CANDIDATES × alpha are known and more than one source listed some (this node's records count as - one), or the lookup is complete: identities that stored records for the name's key first can't take - every slot. Candidates are asked best standing first (rank_keepers). Another batch is asked, up to - keeper_rounds() in all, while no answer reaches the highest sequence already known for the name (from - an announce, a resolve or a kept record), or fewer than two keepers answered (on a slow node: only - while nothing is known to compare against). Each verifying record is returned; the caller keeps the - highest sequence. + Storing a provider record is free, and so is answering /find, so who vouches for a keeper counts as + well as how many: standing (RoutingTable.standing) is read from the table as it was before the search + (a node the lookup meets doesn't gain rank from answering it; one that fails meanwhile loses rank), and + an *established* node is one senior and healthy then. Keepers recorded here are candidates, never a + reason to skip the search, which runs until KEEPER_CANDIDATES × alpha are known and two established + sources listed some (this node counts as one if an established publisher stored a record here), or + the lookup is complete. Candidates are ranked by standing then at random (rank_keepers), and each batch + takes first, in that order, those listed by a source no earlier pick was listed by (this node, an + established listing node, or, as one source, nobody established), then the rest in rank order. Unknown keepers (a path request each) get a budget of + alpha of their own, apart from the lookup's. Another batch is asked, up to keeper_rounds() in all, + while no answer reaches the highest sequence already known for the name (from an announce, a resolve + or a kept record), or fewer than two keepers answered (when no sequence is known, two established + keepers; on a slow node a second batch is asked for this only when nothing is known to compare + against). Each verifying record is returned; the caller keeps the highest sequence. """ node = self.node key = name_key(name) alpha = node.config.effective_alpha(node.slow) banned = node.routing.is_banned + # Taken before the lookup, whose answers add nodes to the routing table + before = node.routing.standings(node.config.provider_seniority) local = [p for p in node.records.providers(key, exclude=banned) if p != node.node_id] found = dict.fromkeys(local) lookup = node._lookup(key) wanted = KEEPER_CANDIDATES * alpha + def established(node_id: bytes) -> bool: + """Senior and healthy in the table before the search, and not failed or gone silent since.""" + return self._standing(node_id, before) == 0 + + # Records stored here count as a source only if an established publisher stored one, so identities + # nobody knows can't end the search by storing records for the key here first + local_source = any(established(p) for p in local) + def add(provider: bytes): """Collect a provider the lookup reports.""" if provider != node.node_id and provider not in found and not banned(provider): found[provider] = None def enough() -> bool: - """Enough candidates, listed by more than one source.""" - return len(found) >= wanted and lookup.listing_count() + (1 if local else 0) >= 2 + """Enough candidates, listed by more than one established source.""" + if len(found) < wanted: + return False + sources = sum(1 for lister in lookup.listing_nodes() if established(lister)) + return sources + (1 if local_source else 0) >= 2 lookup.find_providers(add, should_stop=enough) - candidates = self.rank_keepers(list(found)) + candidates = self.rank_keepers(list(found), before) if not candidates: _log(f"no keepers found for name {name.hex()}") return [] + # Who vouches for each candidate: established nodes that listed it, and this node if it holds a record of + # it; candidates nobody established vouches for count as one source between them + here = set(local) + vouched = {provider: ({lister for lister in lookup.listers(provider) if established(lister)} + | ({node.node_id} if provider in here else set())) or {_UNVOUCHED} + for provider in candidates} floor = self._known_sequence(name) results: list[SignedRecord] = [] answered: set[bytes] = set() lock = threading.Lock() + unknown: set[bytes] = set() # unknown keepers given a path request: at most alpha, apart from the lookup's + + def may_ask(provider: bytes) -> bool: + """True if provider's identity is known, or the unknown-keeper budget has room.""" + if node._is_known(provider) or provider in unknown: + return True + if len(unknown) >= alpha: + return False + unknown.add(provider) + return True + + used: set[bytes] = set() # sources that listed a keeper already picked + + def next_batch() -> list[bytes]: + """Up to alpha candidates: first those listed by a source not used yet, then in rank order.""" + batch = [] + for fresh in (True, False): + for provider in list(candidates): + if len(batch) >= alpha: + break + if fresh and not vouched[provider] - used: + continue + candidates.remove(provider) + if may_ask(provider): + batch.append(provider) + used.update(vouched[provider]) + return batch def ask(provider: bytes): """Ask one keeper; keep its record if it verifies.""" @@ -692,11 +746,7 @@ def ask(provider: bytes): deadline = time.monotonic() + max(1.0, lookup.remaining()) asked = 0 for _ in range(self.keeper_rounds()): - batch = [] - while candidates and len(batch) < alpha: - provider = candidates.pop(0) - if node._is_known(provider) or lookup.allow_unknown(provider): - batch.append(provider) + batch = next_batch() if not batch: break asked += len(batch) @@ -708,11 +758,16 @@ def ask(provider: bytes): thread.join(max(0.0, deadline - time.monotonic())) with lock: best = max((record.sequence for record in results), default=None) - responders = len(answered) + responders = list(answered) current = best is not None and (floor is None or best >= floor) - # A second keeper's answer is only worth its airtime on a slow node if there's nothing to check - # the first against - confirmed = responders >= 2 or (node.slow and floor is not None) + if floor is None: + # Nothing to check answers against: replayed old records from identities that answered the + # lookup must not settle it, so only established keepers confirm each other + confirmed = sum(1 for provider in responders if established(provider)) >= 2 + else: + # A second keeper's answer is only worth its airtime on a slow node if there's nothing to check + # the first against + confirmed = len(responders) >= 2 or node.slow if (current and confirmed) or time.monotonic() >= deadline: break with lock: @@ -725,18 +780,22 @@ def keeper_rounds(self) -> int: alpha is 1 and each /name costs a link and two packets of airtime.""" return 2 if self.node.slow else KEEPER_ROUNDS - def rank_keepers(self, candidates: list[bytes]) -> list[bytes]: + def rank_keepers(self, candidates: list[bytes], before: dict[bytes, int] | None = None) -> list[bytes]: """candidates in the order to ask them: by routing-table standing (RoutingTable.standing: servers known for provider_seniority and healthy first, then healthy ones, then those that failed a query or went silent, such as an owner that has left, then IDs not in the table), then at random, as the fetcher ranks providers by seniority first. Recording a provider record is free, so neither its age nor the - order replies list it in decides.""" - routing = self.node.routing - seniority = self.node.config.provider_seniority - ranked = [(routing.standing(provider, seniority), self._rng.random(), provider) for provider in candidates] + order replies list it in decides. before: RoutingTable.standings() taken before the search, so nodes + added to the table since don't rank above where they stood then.""" + ranked = [(self._standing(provider, before), self._rng.random(), provider) for provider in candidates] ranked.sort() return [provider for _, _, provider in ranked] + def _standing(self, node_id: bytes, before: dict[bytes, int] | None = None) -> int: + """node_id's routing-table standing now, or where it stood in before if that was worse.""" + now = self.node.routing.standing(node_id, self.node.config.provider_seniority) + return now if before is None else max(now, before.get(node_id, 3)) + def _known_sequence(self, name: bytes) -> int | None: """The highest sequence this node knows for name (record held by NameManager, or kept), or None.""" known = [] diff --git a/tests/security/_dhtr2b_helpers.py b/tests/security/_dhtr2b_helpers.py new file mode 100644 index 0000000..6e91bcc --- /dev/null +++ b/tests/security/_dhtr2b_helpers.py @@ -0,0 +1,104 @@ +"""Shared setup for the keeper discovery tests of the second DHT round (test_m3r1_*, test_m3r2_*). + +Offline nodes over SybilNet, a KeeperNet with Sybil identities. Owner A publishes a name at sequence 1, which keeper +C keeps (its /name answer is kept as the replay an attacker would send), then at sequence 2, which keeper D keeps. +A and C then leave, so D is the only keeper holding the current record. Each test adds resolver B (and other +servers) itself, with the routing tables and provider records its case needs. +""" +import io +import os +import tempfile +import unittest + +import RNS + +from rnfs.config import Config +from rnfs.constants import K +from rnfs.features.keepers import name_key +from rnfs.node import RnfsNode +from tests.test_names import FakeNetwork +from tests.test_names_keep import KeeperNet + +A_ID, B_ID, C_ID, D_ID = (bytes([x] * 16) for x in (0xA, 0xB, 0xC, 0xD)) + + +class SybilNet(KeeperNet): + """KeeperNet with Sybil identities: no node behind them, yet live DHT servers. Each answers /find for any key + listing the first K Sybils as providers and as nodes, and /name with sybil_answer (a replayed old record, or []).""" + + def __init__(self): + """No Sybils yet.""" + super().__init__() + self.sybils: list[bytes] = [] + self.sybil_answer = [] + self.find_requests: list[bytes] = [] + + def find(self, node_id, key): + """A Sybil's /find answer, or the node's.""" + self.find_requests.append(node_id) + if node_id in self.sybils: + return list(self.sybils[:K]), list(self.sybils[:K]) + return super().find(node_id, key) + + def name_record(self, node_id, name): + """A Sybil's /name answer, or the node's.""" + if node_id in self.sybils: + self.name_requests.append(node_id) + return self.sybil_answer + return super().name_record(node_id, name) + + +class KeeperScenario(unittest.TestCase): + """A name at sequence 2 kept only by D, with a validly signed replay of sequence 1 (self.old).""" + + def setUp(self): + """The network, the name's two versions, and its keepers.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dir = tmp.name + self.net = SybilNet() + a, c, self.d = self.make_node("a", A_ID), self.make_node("c", C_ID), self.make_node("d", D_ID) + for node, node_id in ((a, A_ID), (c, C_ID), (self.d, D_ID)): + for other in (A_ID, C_ID, D_ID): + if other != node_id: + node.routing.heard(other) + self.name = bytes.fromhex(a.name_create("site")["name"]) + self.key = name_key(self.name) + a.name_publish("site", a.add_stream(io.BytesIO(b"v1"))["cid"]) + self.net.sender = A_ID + a.publisher.publish_due() + keeper = c.feature("keepers") + keeper.keep(self.name) + keeper.run_pending() + self.assertEqual(keeper.store.get(self.name).sequence, 1) + self.old = self.net.name_record(C_ID, self.name) # sequence 1, validly signed by the name's key + a.name_publish("site", a.add_stream(io.BytesIO(b"v2"))["cid"]) + keeper = self.d.feature("keepers") + keeper.keep(self.name) + keeper.run_pending() + self.assertEqual(keeper.store.get(self.name).sequence, 2) + self.d.records.store(self.key, D_ID) # D lists itself in its /find answers + del self.net.nodes[A_ID], self.net.nodes[C_ID] + for gone in (A_ID, C_ID): # long gone: their provider records have expired + self.d.records.withdraw(self.key, gone) + self.net.name_requests.clear() + self.net.find_requests.clear() + + def make_node(self, label: str, node_id: bytes, slow: bool = False) -> RnfsNode: + """An offline node with its own directory.""" + config = Config(rnfs_dir=os.path.join(self.dir, label), slow="no", lookup_timeout=5, path_timeout=0.2) + node = RnfsNode.offline(config, node_id, self.net, FakeNetwork(), slow=slow) + self.net.nodes[node_id] = node + self.addCleanup(node.stop) + return node + + def near_key(self, distance: int) -> bytes: + """The ID at XOR distance `distance` from the name's keeper key (1 is the closest there is).""" + return (int.from_bytes(self.key, "big") ^ distance).to_bytes(16, "big") + + def best(self, records) -> int | None: + """The highest sequence among records from ask_keepers.""" + return max((record.sequence for record in records), default=None) diff --git a/tests/security/test_m3r1_keeper_standing.py b/tests/security/test_m3r1_keeper_standing.py new file mode 100644 index 0000000..ea4652e --- /dev/null +++ b/tests/security/test_m3r1_keeper_standing.py @@ -0,0 +1,87 @@ +"""M3-R1: after the first M3 fix, identities that answer /find could still steer a keeper-fallback resolve: those +the lookup met joined the routing table and outranked keepers recorded here, records stored here by any identity +counted as a source that could end the search, and two answers from such identities "confirmed" a replayed old +record when the resolver knew no sequence for the name. + +Offline nodes over SybilNet (tests/security/_dhtr2b_helpers.py): the name is at sequence 2, kept only by D; Sybils +replay its validly signed sequence 1. Resolver B has never seen the name, so it has no sequence to compare against. +Each test runs B's keeper query (KeepersFeature.ask_keepers) and checks that D was asked and sequence 2 came back. +""" +import random +import unittest +from unittest import mock + +from tests.security._dhtr2b_helpers import B_ID, D_ID, KeeperScenario + +X_ID = bytes([0x55] * 16) # an honest DHT server that keeps nothing +SQUATTERS = [bytes([0xE0 + i] * 16) for i in range(3)] # identities with no node behind them + + +class KeeperStandingTest(KeeperScenario): + """The name at sequence 2 kept only by D; resolver B is added by each test.""" + + def test_sybils_met_during_the_resolve_do_not_outrank_a_recorded_keeper(self): + """B (a slow node) knows only X, whose table holds 10 Sybils close to the key; D's record is stored at B. + Sybils that answer B's lookup join B's table, but must not rank above D for it.""" + x = self.make_node("x", X_ID) + b = self.make_node("b", B_ID, slow=True) + b.routing.heard(X_ID) + self.net.sybils = [self.near_key(i + 1) for i in range(10)] + self.net.sybil_answer = self.old + for sybil in self.net.sybils: + x.routing.heard(sybil) + self.assertTrue(b.records.store(self.key, D_ID)) + keepers = b.feature("keepers") + for trial in range(5): + for sybil in self.net.sybils: + b.routing.remove(sybil) # met afresh by each resolve + keepers._rng = random.Random(trial) + self.net.name_requests.clear() + records = keepers.ask_keepers(self.name) + asked = [node_id.hex()[:6] for node_id in self.net.name_requests] + # M3-R1: fails until keepers are ranked on the routing table as it stood before the lookup + self.assertIn(D_ID, self.net.name_requests, f"trial {trial}: keeper D never asked; asked {asked}") + self.assertEqual(self.best(records), 2, f"trial {trial}: asked {asked}") + + def test_answers_without_standing_do_not_settle_a_resolve_with_nothing_to_compare(self): + """B (alpha 3) holds records of three Sybils and of D, and asks the Sybils first (ranking forced). Their three + replays of sequence 1 agree, but none is an established server, so B must ask further and reach D.""" + b = self.make_node("b", B_ID) + b.routing.heard(D_ID) + self.net.sybils = list(SQUATTERS) + self.net.sybil_answer = self.old + for provider in SQUATTERS + [D_ID]: + self.assertTrue(b.records.store(self.key, provider)) + order = SQUATTERS + [D_ID] + keepers = b.feature("keepers") + with mock.patch.object(keepers, "rank_keepers", + lambda candidates, before=None: sorted( + candidates, key=lambda p: order.index(p) if p in order else len(order))): + records = keepers.ask_keepers(self.name) + asked = [node_id.hex()[:6] for node_id in self.net.name_requests] + self.assertEqual(self.net.name_requests[:3], SQUATTERS) + # M3-R1: fails until only established keepers' answers confirm each other when no sequence is known + self.assertIn(D_ID, self.net.name_requests, f"three replayed answers settled the resolve; asked {asked}") + self.assertEqual(self.best(records), 2) + + def test_records_stored_here_by_unknown_identities_do_not_end_the_search(self): + """B (a slow node, so 3 candidates are wanted) holds records of three squatters; X, the server closest to the + key, lists one of them. Those must not end the search before D, also in B's table, is asked by the lookup.""" + x_id = self.near_key(1) + x = self.make_node("x", x_id) + b = self.make_node("b", B_ID, slow=True) + b.routing.heard(x_id) + b.routing.heard(D_ID) + for squatter in SQUATTERS: + self.assertTrue(b.records.store(self.key, squatter)) + self.assertTrue(x.records.store(self.key, SQUATTERS[0])) + records = b.feature("keepers").ask_keepers(self.name) + found = [node_id.hex()[:6] for node_id in self.net.find_requests] + # M3-R1: fails until records from publishers without standing don't count as a source in enough() + self.assertIn(D_ID, self.net.find_requests, f"the search stopped before reaching D; /find sent to {found}") + self.assertEqual(self.net.name_requests[0], D_ID) + self.assertEqual(self.best(records), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_m3r2_keeper_unknown_budget.py b/tests/security/test_m3r2_keeper_unknown_budget.py new file mode 100644 index 0000000..6401a32 --- /dev/null +++ b/tests/security/test_m3r2_keeper_unknown_budget.py @@ -0,0 +1,53 @@ +"""M3-R2: a keeper query asked keepers whose identity RNS doesn't know (each costs a path request) only within the +lookup's budget of alpha unknown IDs, which the lookup itself spends on nodes /find replies name, so replies naming a +few unknown nodes kept every unknown keeper from being asked. + +Offline nodes over SybilNet (tests/security/_dhtr2b_helpers.py): the name is at sequence 2, kept only by D. Resolver +B (alpha 3) knows only X, whose /find answer lists D as a provider and three unknown, unreachable IDs as the nodes +closest to the key. B's identity knowledge is narrowed to X, so D and those nodes are unknown. The lookup spends its +budget on the three nodes; D must still be asked, from a budget of the keeper query's own. +""" +import unittest + +from tests.security._dhtr2b_helpers import B_ID, D_ID, KeeperScenario + +X_ID = bytes([0x55] * 16) + + +class UnknownKeeperBudgetTest(KeeperScenario): + """The name at sequence 2 kept only by D, which resolver B doesn't know.""" + + def test_unknown_nodes_in_replies_do_not_use_up_the_keepers_budget(self): + x = self.make_node("x", X_ID) + b = self.make_node("b", B_ID) + b._is_known = lambda node_id: node_id == X_ID # RNS knows X's identity only + b.routing.heard(X_ID) + unknown = [self.near_key(i + 1) for i in range(b.config.effective_alpha(b.slow))] + for node_id in unknown: + x.routing.heard(node_id) # no node behind them: B's /find to each fails + self.assertTrue(x.records.store(self.key, D_ID)) + records = b.feature("keepers").ask_keepers(self.name) + self.assertTrue(set(unknown) <= set(self.net.find_requests)) # the lookup spent its budget on them + # M3-R2: fails until unknown keepers have a budget of their own, apart from the lookup's + self.assertIn(D_ID, self.net.name_requests, "the unknown keeper D was never asked") + self.assertEqual(self.best(records), 2) + + def test_unknown_keepers_have_a_budget_of_alpha(self): + """However many unknown keepers there are, alpha are asked per query: none were once the lookup's budget was + spent, and no more than alpha now.""" + x = self.make_node("x", X_ID) + b = self.make_node("b", B_ID) + b._is_known = lambda node_id: node_id == X_ID + b.routing.heard(X_ID) + alpha = b.config.effective_alpha(b.slow) + for i in range(alpha): + x.routing.heard(self.near_key(i + 1)) + strangers = [bytes([0xE0 + i] * 16) for i in range(2 * alpha)] # unknown, never answer + for stranger in strangers: + self.assertTrue(x.records.store(self.key, stranger)) + b.feature("keepers").ask_keepers(self.name) + # M3-R2: fails until unknown keepers have a budget of their own, apart from the lookup's + self.assertEqual(len(self.net.name_requests), alpha) + +if __name__ == "__main__": + unittest.main() From 2f72d80985e7283de53eab3e442d2442a2ace031 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 089/107] Evict from the newcomer pool's biggest holder, and remember first-seen times of newcomers the pool evicted --- rnfs/dht/providers.py | 143 +++++++++++++++++------ tests/security/test_m4r_newcomer_pool.py | 100 ++++++++++++++++ 2 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 tests/security/test_m4r_newcomer_pool.py diff --git a/rnfs/dht/providers.py b/rnfs/dht/providers.py index d7856c1..bde89d1 100644 --- a/rnfs/dht/providers.py +++ b/rnfs/dht/providers.py @@ -4,8 +4,10 @@ their first-stored time across republishes. At most max_records are kept, a tenth of them in a newcomer pool. When the rest is full, a publisher seen before evicts a record of the publisher first seen most recently, so a flood of new identities can't displace older publishers; a publisher not seen before (or the -one seen most recently) stores into the pool instead, evicting a random pooled record when it is full, so -a new publisher can always store something. This node's own records are always accepted. +one seen most recently) stores into the pool instead. A full pool evicts a record of the publisher holding the +most pooled records (one of them at random, if several hold as many), never the storer's own, so a new +publisher can always store something and a flood of identities evens out the pool rather than emptying it of +everyone else's records. This node's own records are always accepted. Local limits, not part of the protocol, bound what one identity can take: a publisher holds at most max_records // PUBLISHER_SHARE records once it has been seen for `seniority`, and a tenth of that before (so @@ -14,7 +16,9 @@ stay cheap however many identities advertise one key). A newcomer to a full key evicts one of its publishers at random, never one of the K/2 oldest records: refusing newcomers would let early Sybil identities hold the key, and the random half of /find replies relies on newcomers getting stored. A publisher's first-seen time -is kept for a TTL after its last record goes, so one that was away for a while isn't new again. +is kept for a TTL after its last record goes, so one that was away for a while isn't new again; that of a +newcomer whose last record was evicted from the pool is kept too, in a smaller memory that forgets the most +recently first seen first, so a flood can't keep a new publisher new forever. Times are wall clock and persisted, so a time in the future (the clock ran ahead, then was corrected) is clamped to now on load and at each expire(); otherwise such records would outlive their TTL. @@ -38,9 +42,10 @@ PUBLISHER_SHARE = 100 # a publisher may hold at most max_records // PUBLISHER_SHARE records ... MIN_PER_PUBLISHER = 10 # ... but at least this many, so small stores still work NEWCOMER_SHARE = 10 # until seen for `seniority`, a publisher may hold a tenth of that (at least MIN_PER_PUBLISHER) -POOL_SHARE = 10 # max_records // POOL_SHARE records are a newcomer pool, with random eviction inside it +POOL_SHARE = 10 # max_records // POOL_SHARE records are a newcomer pool, evicting from its biggest holder MAX_PUBLISHERS_PER_KEY = 1000 # publishers recorded per key; a newcomer evicts a random non-senior one DEPARTED_SHARE = 10 # first-seen times kept for publishers with no records left: at most max_records // this +EVICTED_SHARE = 10 # ... and for newcomers evicted from the pool: at most pool_size // this (MIN_PER_PUBLISHER) def _never(node_id: bytes) -> bool: @@ -82,7 +87,8 @@ class ProviderStore: `clock` is wall-clock seconds, since times are persisted. `state_path` (None: memory only) is where save() writes records and first-seen times as JSON; it is loaded on construction. `rng` picks the - random half of /find replies, the publisher a full key evicts and the pooled record a newcomer evicts. + random half of /find replies, the publisher a full key evicts and, among the publishers holding the most + pooled records, the one a full pool evicts from. """ def __init__(self, max_records: int, state_path: str | None, ttl: float = PROVIDER_TTL, @@ -116,11 +122,21 @@ def __init__(self, max_records: int, state_path: str | None, ttl: float = PROVID # Publishers with records outside the pool, newest first: (-first_seen, -order, publisher). Entries go # stale as publishers lose their last such record and are dropped when they reach the top self._newest: list[tuple[float, int, bytes]] = [] - self._pool: list[tuple[bytes, bytes]] = [] # pooled (key, publisher) records, for a random pick - self._pool_index: dict[tuple[bytes, bytes], int] = {} # position in _pool + self._pooled: dict[bytes, dict[bytes, None]] = {} # publisher -> its pooled keys, oldest pooled first + self._pool_count = 0 + # Publishers by how many pooled records they hold (count -> list, with each one's place in its list), so + # a full pool finds one of its biggest holders in O(1) + self._holders: dict[int, list[bytes]] = {} + self._holder_index: dict[bytes, int] = {} + self._most_pooled = 0 # Publishers whose last record went: publisher -> (first seen, when it went), oldest departure first self._departed: OrderedDict[bytes, tuple[float, float]] = OrderedDict() self._departed_size = max(MIN_PER_PUBLISHER, max(0, max_records) // DEPARTED_SHARE) + # Newcomers (not seen for `seniority`) whose last record a full pool evicted: publisher -> (first seen, + # when evicted), oldest eviction first. When full, the one first seen most recently is forgotten, so a + # flood of fresh identities can't push out a newcomer seen before it + self._evicted: dict[bytes, tuple[float, float]] = {} + self._evicted_size = max(MIN_PER_PUBLISHER, self.pool_size // EVICTED_SHARE) self._count = 0 self._main_count = 0 self._dirty = False @@ -169,14 +185,14 @@ def store(self, key: bytes, publisher: bytes) -> bool: if entry is None: first_seen = self._departed_first_seen(publisher, now) self._departed.pop(publisher, None) + self._evicted.pop(publisher, None) entry = self._publishers[publisher] = _Publisher(now if first_seen is None else first_seen, next(self._order)) entry.keys.add(key) record = self._records.setdefault(key, {})[publisher] = _Record(now, now, pooled) self._count += 1 if pooled: - self._pool_index[(key, publisher)] = len(self._pool) - self._pool.append((key, publisher)) + self._pool_add(key, publisher) else: self._add_main(publisher, entry) self._dirty = True @@ -222,6 +238,7 @@ def retain(self, keep: Callable[[bytes], bool]) -> int: removed += self._remove(key, publisher) # Not kept as departed either: its standing here came from records it shouldn't have had self._departed.pop(publisher, None) + self._evicted.pop(publisher, None) self._dirty = self._dirty or removed > 0 return removed @@ -312,12 +329,19 @@ def _make_room(self, publisher: bytes, now: float) -> bool | None: return False if self.pool_size <= 0: return None - if len(self._pool) < self.pool_size: + if self._pool_count < self.pool_size: return True - key, victim_id = self._pool[self.rng.randrange(len(self._pool))] - if victim_id == publisher and not own: - return None # a publisher can't churn its own pooled records - self._remove(key, victim_id) + # Fair shares: a publisher already holding as many pooled records as anyone can't push others out (nor + # churn its own); any other takes a record from one of those holding the most + if not own and len(self._pooled.get(publisher, ())) >= self._most_pooled: + return None + holders = self._holders[self._most_pooled] + index = self.rng.randrange(len(holders)) + victim_id = holders[index] + if victim_id == publisher and len(holders) > 1: # own records: another's first, if another holds as many + victim_id = holders[(index + 1) % len(holders)] + key = next(iter(self._pooled[victim_id])) # the one it had pooled first + self._remove(key, victim_id, evicted=True) return True @staticmethod @@ -366,16 +390,45 @@ def _unpool(self, key: bytes, publisher: bytes, record: _Record): record.pooled = False self._add_main(publisher, self._publishers[publisher]) + def _pool_add(self, key: bytes, publisher: bytes): + """Count a record of publisher in the pool. Caller holds the lock.""" + keys = self._pooled.setdefault(publisher, {}) + keys[key] = None + self._pool_count += 1 + self._recount(publisher, len(keys) - 1, len(keys)) + def _drop_from_pool(self, key: bytes, publisher: bytes): - """Forget a pooled record's place in the pool, swapping the last one in. Caller holds the lock.""" - index = self._pool_index.pop((key, publisher)) - last = self._pool.pop() - if index < len(self._pool): - self._pool[index] = last - self._pool_index[last] = index - - def _remove(self, key: bytes, publisher: bytes) -> bool: - """Delete one record, and the publisher once it has none left. Caller holds the lock.""" + """Forget a pooled record. Caller holds the lock.""" + keys = self._pooled[publisher] + del keys[key] + if not keys: + del self._pooled[publisher] + self._pool_count -= 1 + self._recount(publisher, len(keys) + 1, len(keys)) + + def _recount(self, publisher: bytes, old: int, new: int): + """Move publisher from the holders of old pooled records to those of new (0: none). Caller holds the lock.""" + if old: + holders = self._holders[old] + index = self._holder_index.pop(publisher) + last = holders.pop() + if index < len(holders): + holders[index] = last + self._holder_index[last] = index + if not holders: + del self._holders[old] + if new: + holders = self._holders.setdefault(new, []) + self._holder_index[publisher] = len(holders) + holders.append(publisher) + # Counts move by one, so the most held drops by at most one + self._most_pooled = max(self._most_pooled, new) + if self._most_pooled and self._most_pooled not in self._holders: + self._most_pooled -= 1 + + def _remove(self, key: bytes, publisher: bytes, evicted: bool = False) -> bool: + """Delete one record, and the publisher once it has none left (evicted: a full pool took it). Caller holds + the lock.""" records = self._records.get(key) if records is None: return False @@ -394,24 +447,36 @@ def _remove(self, key: bytes, publisher: bytes) -> bool: entry.main -= 1 if not entry.keys: del self._publishers[publisher] - self._depart(publisher, entry.first_seen, self.clock()) + self._depart(publisher, entry.first_seen, self.clock(), evicted) return True - def _depart(self, publisher: bytes, first_seen: float, now: float): + def _depart(self, publisher: bytes, first_seen: float, now: float, evicted: bool = False): """Keep the first-seen time of a publisher whose last record went, for a TTL, if it had been seen for `seniority` (a younger one has no standing to lose, and leaving those out means a flood of fresh - identities can't push the others out). Caller holds the lock.""" + identities can't push the others out). A younger one whose last record a full pool evicted is kept + in a memory of its own, so being pushed out doesn't make it new again. Caller holds the lock.""" self._departed.pop(publisher, None) + self._evicted.pop(publisher, None) if now - first_seen < self.seniority: + if evicted: + self._remember_evicted(publisher, first_seen, now) return self._departed[publisher] = (first_seen, now) while len(self._departed) > self._departed_size: self._departed.popitem(last=False) + def _remember_evicted(self, publisher: bytes, first_seen: float, now: float): + """Keep a pool-evicted newcomer's first-seen time; when the memory is full, the one first seen most + recently (perhaps this one) is forgotten. Caller holds the lock.""" + self._evicted[publisher] = (first_seen, now) + if len(self._evicted) > self._evicted_size: + newest = max(self._evicted, key=lambda p: self._evicted[p][0]) + del self._evicted[newest] + def _departed_first_seen(self, publisher: bytes, now: float) -> float | None: """First-seen time of a publisher whose last record went less than a TTL ago, else None. Caller holds the lock.""" - departed = self._departed.get(publisher) + departed = self._departed.get(publisher) or self._evicted.get(publisher) if departed is None or now - departed[1] >= self.ttl: return None return departed[0] @@ -442,6 +507,9 @@ def _expire(self, now: float) -> int: while self._departed and now - next(iter(self._departed.values()))[1] >= self.ttl: self._departed.popitem(last=False) self._dirty = True + while self._evicted and now - next(iter(self._evicted.values()))[1] >= self.ttl: + del self._evicted[next(iter(self._evicted))] + self._dirty = True self._dirty = self._dirty or bool(expired) return len(expired) @@ -463,10 +531,11 @@ def _clamp(self, now: float): if entry.first_seen > now: entry.first_seen = now moved = True - for publisher, (first_seen, departed) in list(self._departed.items()): - if first_seen > now or departed > now: - self._departed[publisher] = (min(first_seen, now), min(departed, now)) - self._dirty = True + for memory in (self._departed, self._evicted): + for publisher, (first_seen, departed) in list(memory.items()): + if first_seen > now or departed > now: + memory[publisher] = (min(first_seen, now), min(departed, now)) + self._dirty = True if moved: self._rebuild_newest() self._dirty = True @@ -558,6 +627,8 @@ def save(self): for key, records in self._records.items() for publisher, record in records.items()], "departed": [[publisher.hex(), first_seen, departed] for publisher, (first_seen, departed) in self._departed.items()], + "evicted": [[publisher.hex(), first_seen, evicted] + for publisher, (first_seen, evicted) in self._evicted.items()], } self._dirty = False directory = os.path.dirname(os.path.abspath(self.state_path)) @@ -584,6 +655,7 @@ def _load(self): records = [(bytes.fromhex(r[0]), bytes.fromhex(r[1]), float(r[2]), float(r[3]), len(r) > 4 and bool(r[4])) for r in state["records"]] departed = [(bytes.fromhex(p), float(seen), float(left)) for p, seen, left in state.get("departed", [])] + evicted = [(bytes.fromhex(p), float(seen), float(left)) for p, seen, left in state.get("evicted", [])] except (OSError, ValueError, KeyError, TypeError, AttributeError, OverflowError, IndexError): return now = self.clock() @@ -605,7 +677,7 @@ def _load(self): continue # Where it was if there is room there, else wherever there is main_room = self._main_count < self.main_size - pool_room = len(self._pool) < self.pool_size + pool_room = self._pool_count < self.pool_size if not (main_room or pool_room): continue pooled = pool_room if pooled or not main_room else False @@ -614,8 +686,7 @@ def _load(self): entry.keys.add(key) self._count += 1 if pooled: - self._pool_index[(key, publisher)] = len(self._pool) - self._pool.append((key, publisher)) + self._pool_add(key, publisher) else: self._main_count += 1 entry.main += 1 @@ -626,3 +697,7 @@ def _load(self): if (len(publisher) == ID_LENGTH and seen == seen and left == left and publisher not in self._publishers and now - min(left, now) < self.ttl): self._depart(publisher, min(seen, now), min(left, now)) + for publisher, seen, left in sorted(evicted, key=lambda item: item[2]): + if (len(publisher) == ID_LENGTH and seen == seen and left == left and publisher not in self._publishers + and publisher not in self._departed and now - min(left, now) < self.ttl): + self._remember_evicted(publisher, min(seen, now), min(left, now)) diff --git a/tests/security/test_m4r_newcomer_pool.py b/tests/security/test_m4r_newcomer_pool.py new file mode 100644 index 0000000..2d23fcd --- /dev/null +++ b/tests/security/test_m4r_newcomer_pool.py @@ -0,0 +1,100 @@ +"""M4-R: after the first M4 fix, a flood of fresh identities churned the newcomer pool at random, so an honest +newcomer kept almost none of its records (1 of 100 against 600 identities), and once its last record was evicted +its first-seen time went with it: it was new again, and so could never age into a full share. + +A ProviderStore whose space outside the pool is full of older publishers (as tests/security/test_m4_* do), then an +honest newcomer H, then a flood of new identities storing into the pool. H must keep about a fair share of the pool, +and its first-seen time must outlast the eviction of its last record (across a restart too), while that memory +stays bounded. +""" +import os +import random +import tempfile +import unittest + +from rnfs.constants import DAY, ID_LENGTH +from rnfs.dht.providers import ProviderStore +from tests.support import FakeClock + +H = bytes([0x48] * ID_LENGTH) + + +def ident(n: int) -> bytes: + """The 16-byte ID whose big-endian value is n.""" + return n.to_bytes(ID_LENGTH, "big") + + +def fill_main(store: ProviderStore, clock: FakeClock): + """Fill the store outside the pool with publishers seen two days ago (so at their full share).""" + publishers = [ident((0x0F << 120) + i) for i in range(store.main_size // store.max_per_publisher)] + for i, publisher in enumerate(publishers): + store.store(ident((1 << 100) + i), publisher) + clock.now += 2 * DAY + for i, publisher in enumerate(publishers): + for n in range(store.max_per_publisher): + store.store(ident((1 << 100) + i * 10_000 + n), publisher) + assert store.record_count() == store.main_size, store.record_count() + + +class NewcomerPoolTest(unittest.TestCase): + """Stores with provider_seniority one day and seeded eviction picks.""" + + def make_store(self, max_records: int, path: str | None = None) -> ProviderStore: + """A store on self.clock, its newcomer allowance a tenth of the full one when large enough.""" + return ProviderStore(max_records, path, clock=self.clock, rng=random.Random(1), seniority=DAY) + + def setUp(self): + """The clock.""" + self.clock = FakeClock(1_700_000_000.0) + + def test_newcomer_keeps_a_fair_share_of_the_pool(self): + store = self.make_store(1000) + fill_main(store, self.clock) + keys = [ident((2 << 100) + i) for i in range(store.newcomer_per_publisher)] + self.assertEqual(sum(store.store(key, H) for key in keys), len(keys)) + identities = 30 + for i in range(identities): + for n in range(store.newcomer_per_publisher): + store.store(ident((3 << 100) + i * 100 + n), ident((0x5 << 120) + i)) + kept = sum(H in store.providers(key) for key in keys) + fair = store.pool_size // (identities + 1) + # M4-R: fails until a full pool evicts from the publisher holding the most pooled records + self.assertGreaterEqual(kept, fair, f"H kept {kept} of {len(keys)} records; a fair share of the pool is {fair}") + + def test_first_seen_outlasts_eviction_from_the_pool(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "providers.json") + store = self.make_store(10_000, path) + fill_main(store, self.clock) + self.assertTrue(store.store(ident(2 << 100), H)) + first_seen = store.first_seen(H) + self.clock.now += 60 + for i in range(3 * store.pool_size): # fresh identities, one record each + self.clock.now += 1 + store.store(ident((3 << 100) + i), ident((0x5 << 120) + i)) + self.assertIsNone(store.first_seen(H), "H still holds a record: the flood didn't evict it") + store.save() + store = self.make_store(10_000, path) # and a restart + self.clock.now += DAY + # M4-R: fails until first-seen times of newcomers evicted from the pool are kept + self.assertEqual(store.allowance(H), store.max_per_publisher, + "H, first seen over a day ago, is new again after the flood") + self.assertTrue(store.store(ident(4 << 100), H)) + self.assertEqual(store.first_seen(H), first_seen) + + def test_memory_of_evicted_newcomers_is_bounded(self): + """Bounded, and the newcomers first seen most recently are forgotten first, so a flood can't push out H.""" + store = self.make_store(1000) + fill_main(store, self.clock) + self.assertTrue(store.store(ident(2 << 100), H)) + for i in range(20 * store.pool_size): + self.clock.now += 1 + store.store(ident((3 << 100) + i), ident((0x5 << 120) + i)) + self.assertIsNone(store.first_seen(H)) + # M4-R: fails until first-seen times of newcomers evicted from the pool are kept + self.assertIsNotNone(store._departed_first_seen(H, self.clock.now), "H's first-seen time was forgotten") + self.assertLessEqual(len(store._evicted), store._evicted_size) + + +if __name__ == "__main__": + unittest.main() From a9c5640cbbf4e6fd488e6de05087d56f742b7c23 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 090/107] Save the name floor when a resolve raises it, at most every 5 s, and keep names resolved in the last week past the floor's usual size --- rnfs/names.py | 100 +++++++++++++++------ tests/security/test_l4r_name_floor.py | 120 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 tests/security/test_l4r_name_floor.py diff --git a/rnfs/names.py b/rnfs/names.py index 228c33f..b070885 100644 --- a/rnfs/names.py +++ b/rnfs/names.py @@ -5,11 +5,14 @@ any validly signed announce, including a replayed old one, so the highest sequence seen is persisted for every name this node publishes or resolves for the user, and lower sequences are never accepted from any source. Names resolved for the gateway or LXMF auto-fetch keep their records in a bounded table, where announces of -names this node never resolved can't push them out, and their highest sequence in a bounded persisted floor. +names this node never resolved can't push them out, and their highest sequence in a bounded persisted floor, saved +within seconds of a resolve raising it, where names resolved in the last FLOOR_KEEP_AGE are forgotten only past a +hard bound. Every RNS call goes through `RnsNetwork`, so tests swap in a fake and run without Reticulum. """ import json +import math import os import re import threading @@ -38,7 +41,11 @@ STATE_VERSION = 1 FLOOR_FILE = "name-floor.json" FLOOR_VERSION = 1 -FLOOR_FACTOR = 4 # the floor holds up to this many times name_table_size names, so it outlasts table churn +FLOOR_FACTOR = 4 # the floor holds up to this many times name_table_size names, so it outlasts table churn ... +FLOOR_KEEP_AGE = 7 * 24 * 3600.0 # ... except that names resolved this recently (seconds) are kept past that ... +FLOOR_HARD_FACTOR = 2 # ... up to this many times as many in all (the file stays a few MB at the defaults) +FLOOR_SAVE_INTERVAL = 5.0 # seconds: a resolve raising the floor saves it at once, or this long after the last save +FLOOR_TOUCH_SAVE = 3600.0 # a resolve that only renews a floor entry's time marks it for the next save after this PIN_SOURCE_PREFIX = "name:" _ALIAS = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") _POLL_INTERVAL = 0.25 # how often a refresh checks whether RNS has heard a path response @@ -237,12 +244,15 @@ def __init__(self, config: Config, pins, network=None, clock=time.time, monotoni # name this node isn't resolving and doesn't know may only take the place of one of these, so a flood of # new names can't push out the names it resolved self._heard_only: OrderedDict[bytes, None] = OrderedDict() - # Highest sequence of each name resolved without persist (gateway, LXMF auto-fetch), least recently resolved - # first, at most FLOOR_FACTOR × name_table_size: saved, so such a resolve never goes back after table churn - # or a restart - self._floor: OrderedDict[bytes, int] = OrderedDict() + # Highest sequence of each name resolved without persist (gateway, LXMF auto-fetch), and when it was last + # resolved (wall clock), least recently resolved first; at most FLOOR_FACTOR × name_table_size, more (up to + # FLOOR_HARD_FACTOR times that) while those resolved within FLOOR_KEEP_AGE need the room. Saved, so such a + # resolve never goes back after table churn or a restart + self._floor: OrderedDict[bytes, tuple[int, float]] = OrderedDict() self._floor_path = os.path.join(config.state_dir, FLOOR_FILE) self._floor_dirty = False + self._floor_saved_at = float("-inf") # monotonic time of the last floor save + self._floor_timer: threading.Timer | None = None # a save waiting for FLOOR_SAVE_INTERVAL to pass self._held_dirty = False # heard times changed without a save self._destinations: dict[str, object] = {} @@ -503,6 +513,9 @@ def stop(self): for events in self._waiters.values(): for event in events: event.set() + if self._floor_timer is not None: + self._floor_timer.cancel() + self._floor_timer = None self._save() self._save_floor() @@ -693,7 +706,7 @@ def _offer(self, name: bytes, sequence: int, cid: Cid, heard: float, unasked: bo if it is free or held by another name that was only heard.""" held_here = name in self._held current = self._record(name, touch=False) - floor = None if held_here else self._floor.get(name) + floor = None if held_here or name not in self._floor else self._floor[name][0] if floor is not None and sequence < floor: log(f"refused record {sequence} for name {name.hex()}: already resolved {floor}", LOG_VERBOSE) return False @@ -738,8 +751,8 @@ def _store(self, name: bytes, record: Record, held_here: bool, unasked: bool = F self._table[name] = record self._touch(name) floor = self._floor.get(name) - if floor is not None and record.sequence > floor: - self._floor[name] = record.sequence + if floor is not None and record.sequence > floor[0]: + self._floor[name] = (record.sequence, floor[1]) self._floor_dirty = True if len(self._table) > limit: # name_table_size 0: nothing is kept self._drop_from_table(name) @@ -758,18 +771,52 @@ def _drop_from_table(self, name: bytes): def _resolved(self, name: bytes, sequence: int): """A resolve without persist returned name's record: it is no longer only heard, and its sequence is the - floor for later resolves (least recently resolved forgotten first).""" + floor for later resolves (_trim_floor says which are forgotten). A floor raised (or a new one) is saved at + once, or FLOOR_SAVE_INTERVAL after the last save, so a crash soon after loses little.""" self._heard_only.pop(name, None) + now = self._clock() previous = self._floor.pop(name, None) - self._floor[name] = sequence if previous is None else max(previous, sequence) - if self._floor[name] != previous: + self._floor[name] = (sequence if previous is None else max(previous[0], sequence), now) + trimmed = self._trim_floor() + if previous is None or self._floor[name][0] != previous[0] or trimmed: self._floor_dirty = True - while len(self._floor) > self._floor_size(): + self._save_floor_soon() + elif not 0 <= now - previous[1] < FLOOR_TOUCH_SAVE: + self._floor_dirty = True # only its time renewed: saved with the next change or by announce_due + + def _trim_floor(self) -> bool: + """Forget the least recently resolved names over FLOOR_FACTOR × name_table_size, but not those resolved + within FLOOR_KEEP_AGE unless the floor holds FLOOR_HARD_FACTOR times that. True if any went.""" + size = self._floor_size() + now = self._clock() + trimmed = False + while len(self._floor) > size: + _, at = next(iter(self._floor.values())) + if len(self._floor) <= FLOOR_HARD_FACTOR * size and now - at < FLOOR_KEEP_AGE: + break self._floor.popitem(last=False) - self._floor_dirty = True + trimmed = True + return trimmed + + def _save_floor_soon(self): + """Save the floor now if the last save was FLOOR_SAVE_INTERVAL ago, else once it is (a timer).""" + wait = self._floor_saved_at + FLOOR_SAVE_INTERVAL - self._monotonic() + if wait <= 0: + self._save_floor() + elif self._floor_timer is None: + timer = threading.Timer(wait, self._save_floor_later) + timer.daemon = True + self._floor_timer = timer + timer.start() + + def _save_floor_later(self): + """The timer's save.""" + with self._lock: + self._floor_timer = None + self._save_floor() def _floor_size(self) -> int: - """How many names the floor holds at most.""" + """How many names the floor holds, past which only names resolved within FLOOR_KEEP_AGE are kept.""" return FLOOR_FACTOR * max(0, self.config.name_table_size) # RNS destinations; callers hold the lock @@ -912,29 +959,33 @@ def _load_state(self): def _load_floor(self): """Read the floor of names resolved without persist. A damaged file is moved aside and the node starts without it (it only bounds rollback of gateway and auto-fetch resolves); entries that don't parse are - skipped.""" + skipped. An entry without a valid resolve time (written before they were kept) counts as resolved long + ago, and one in the future as now.""" state = read_json(self._floor_path, shape=lambda value: isinstance(value, dict) and value.get("version") == FLOOR_VERSION and isinstance(value.get("floor"), list)) if state is None: return + now = self._clock() for entry in state["floor"]: try: - name_hex, sequence = entry + name_hex, sequence, *at = entry name = parse_name(name_hex) except (TypeError, ValueError): continue + at = at[0] if len(at) == 1 and type(at[0]) in (int, float) and math.isfinite(at[0]) else 0.0 if type(sequence) is int and 0 <= sequence <= MAX_SEQUENCE and name not in self._held: - self._floor.pop(name, None) - self._floor[name] = sequence - while len(self._floor) > self._floor_size(): - self._floor.popitem(last=False) + earlier = self._floor.pop(name, None) # listed twice (a damaged file): the higher sequence holds + self._floor[name] = (max(sequence, earlier[0] if earlier else 0), min(float(at), now)) + self._trim_floor() def _save_floor(self): - """Write the floor atomically, least recently resolved first, if it changed. Saved by announce_due (at - least every minute while running) and stop(), not on every resolve, since it holds thousands of names.""" + """Write the floor atomically, least recently resolved first, as [name, sequence, resolved], if it + changed. Saved by a resolve that raised it (at most every FLOOR_SAVE_INTERVAL, since it holds thousands + of names), announce_due (at least every minute while running) and stop().""" if not self._floor_dirty: return - state = {"version": FLOOR_VERSION, "floor": [[name.hex(), sequence] for name, sequence in self._floor.items()]} + state = {"version": FLOOR_VERSION, + "floor": [[name.hex(), sequence, int(at)] for name, (sequence, at) in self._floor.items()]} os.makedirs(self.config.state_dir, exist_ok=True) try: fsutil.write_atomic(self._floor_path, json.dumps(state, separators=(",", ":")).encode()) @@ -942,6 +993,7 @@ def _save_floor(self): log(f"could not save {self._floor_path}: {e}", LOG_WARNING) return self._floor_dirty = False + self._floor_saved_at = self._monotonic() def _save(self): """Write published and held records atomically.""" diff --git a/tests/security/test_l4r_name_floor.py b/tests/security/test_l4r_name_floor.py new file mode 100644 index 0000000..10d5f84 --- /dev/null +++ b/tests/security/test_l4r_name_floor.py @@ -0,0 +1,120 @@ +"""L4-R: after the L4 fix, the floor of names resolved for the gateway (persist=False) was saved only by the +re-announce loop, at most a minute later, so a crash soon after a resolve lost it; and it forgot the least recently +resolved names once it held 4 × name_table_size, so that many resolves of other names, however recent, let a +replayed old announce of a name back in. + +A NameManager over FakeNetwork (as tests/security/test_l4_name_rollback.py) resolves name N at sequence 5 for the +gateway. A "crash" is a second manager on the same state directory without stop(). The floor must be on disk at once +(within FLOOR_SAVE_INTERVAL of the last save), names resolved within FLOOR_KEEP_AGE must outlast churn up to a hard +bound, and the file must stay bounded. +""" +import json +import os +import tempfile +import unittest + +import RNS + +from rnfs import names +from rnfs.config import Config +from rnfs.content.cid import RAW, Cid +from rnfs.names import NameManager, encode_record +from tests.support import FakeClock +from tests.test_names import FakeNetwork, FakePins + +N = bytes([0x4E] * 16) +OLD = Cid.for_block(RAW, b"old") +NEW = Cid.for_block(RAW, b"new") +HARD_FACTOR = 2 # rnfs.names.FLOOR_HARD_FACTOR: recent names are kept up to twice the usual floor + + +def other_name(i: int) -> bytes: + """The i-th other name, never N.""" + return (0x10 ** 30 + i).to_bytes(16, "big") + + +class FloorTest(unittest.TestCase): + """A started manager with a table of 3 names (so a floor of 12), which resolved N at sequence 5.""" + + def setUp(self): + """The manager, and N resolved for the gateway.""" + saved = RNS.loglevel + RNS.loglevel = RNS.LOG_CRITICAL + self.addCleanup(setattr, RNS, "loglevel", saved) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.config = Config(rnfs_dir=tmp.name, path_timeout=0.05, name_table_size=3) + self.network = FakeNetwork() + self.clock = FakeClock(1_000_000.0) + self.manager = self.start_manager() + self.network.announce(N, encode_record(5, NEW), self.clock.now) + self.assertEqual(self.manager.resolve(N, persist=False)["sequence"], 5) + + def start_manager(self) -> NameManager: + """A manager on the state directory, started.""" + manager = NameManager(self.config, FakePins(), network=self.network, clock=self.clock, monotonic=self.clock) + manager.start() + self.addCleanup(manager.stop) + return manager + + def resolve_other(self, i: int): + """A gateway resolve of another name, announced at sequence 1.""" + self.network.announce(other_name(i), encode_record(1, OLD), self.clock.now) + self.manager.resolve(other_name(i), persist=False) + + def replayed_resolve(self, manager: NameManager): + """N's older announce arrives again; the gateway resolves N with manager.""" + self.clock.now += 10 + self.network.app_data.clear() + self.network.announce(N, encode_record(3, OLD), self.clock.now) + return manager.resolve(N, persist=False) + + def saved_names(self) -> set[bytes]: + """The names in name-floor.json (none if there is no such file).""" + try: + with open(os.path.join(self.config.state_dir, names.FLOOR_FILE), encoding="utf-8") as f: + return {bytes.fromhex(entry[0]) for entry in json.load(f)["floor"]} + except FileNotFoundError: + return set() + + def test_floor_survives_a_crash_right_after_the_resolve(self): + crashed = self.start_manager() # the first one never stopped, nor reached announce_due + result = self.replayed_resolve(crashed) + # L4-R: fails until a resolve that raises the floor saves it promptly + self.assertTrue(result is None or result["sequence"] >= 5, f"resolve after a crash went back from 5: {result}") + + def test_saves_are_rate_limited(self): + """A second raise within FLOOR_SAVE_INTERVAL of a save waits; the first after it saves both.""" + # L4-R: fails until a resolve that raises the floor saves it promptly + self.assertIn(N, self.saved_names(), "the floor wasn't saved by the resolve") + self.resolve_other(1) + self.assertNotIn(other_name(1), self.saved_names()) + self.clock.now += names.FLOOR_SAVE_INTERVAL + self.resolve_other(2) + self.assertTrue({N, other_name(1), other_name(2)} <= self.saved_names()) + + def test_recent_names_outlast_churn(self): + for i in range(names.FLOOR_FACTOR * 3 + 5): + self.resolve_other(i) + result = self.replayed_resolve(self.manager) + # L4-R: fails until names resolved within FLOOR_KEEP_AGE are kept past FLOOR_FACTOR × name_table_size + self.assertTrue(result is None or result["sequence"] >= 5, f"gateway resolve went back from 5: {result}") + + def test_floor_stays_bounded(self): + hard = HARD_FACTOR * names.FLOOR_FACTOR * 3 + for i in range(hard + 5): + self.resolve_other(i) + # L4-R: fails until recent names are kept up to the hard bound (the old floor held FLOOR_FACTOR × 3) + self.assertEqual(len(self.manager._floor), hard) + self.assertEqual(names.FLOOR_HARD_FACTOR, HARD_FACTOR) + self.assertNotIn(N, self.manager._floor) # the least recently resolved went first + # Once they are older than FLOOR_KEEP_AGE, the next resolve brings the floor back to its usual size + self.clock.now += names.FLOOR_KEEP_AGE + self.resolve_other(hard + 5) + self.assertEqual(len(self.manager._floor), names.FLOOR_FACTOR * 3) + self.manager.stop() + self.assertEqual(len(self.saved_names()), names.FLOOR_FACTOR * 3) + + +if __name__ == "__main__": + unittest.main() From dc1f71d73ddfb16935ffc37f46a8ffd35494de44 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 091/107] Give each untrusted LXMF sender an allowance before the node-wide bucket, and let a held message take the place of its claimed sender's oldest --- rnfs/features/lxmf.py | 49 +++++++--- .../test_l12r_untrusted_sender_allowance.py | 92 +++++++++++++++++++ .../test_l13r_hold_replaces_oldest.py | 45 +++++++++ 3 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 tests/security/test_l12r_untrusted_sender_allowance.py create mode 100644 tests/security/test_l13r_hold_replaces_oldest.py diff --git a/rnfs/features/lxmf.py b/rnfs/features/lxmf.py index 85a2124..f69fb42 100644 --- a/rnfs/features/lxmf.py +++ b/rnfs/features/lxmf.py @@ -34,7 +34,7 @@ import sys import threading import time -from collections import deque +from collections import OrderedDict, deque from contextlib import contextmanager from dataclasses import dataclass from urllib.parse import quote, unquote_to_bytes @@ -69,6 +69,10 @@ # dropped only for each other's. Others' new records come from a node-wide token bucket, since identities are free UNTRUSTED_INTERVAL = 60.0 # seconds per token UNTRUSTED_BURST = 32 +# ... and before that from a small allowance per sender, so one sender can't keep the node-wide bucket empty +SENDER_INTERVAL = 900.0 # seconds per token +SENDER_BURST = 4 +SENDER_ALLOWANCES = 256 # senders whose allowance is remembered, least recently seen forgotten (back to full) MAX_ENTRY_BYTES = 16 * 1024 # an inbox record as JSON: links beyond it are dropped, then text is cut MAX_INBOX_BYTES = 4 * 1024 * 1024 # every inbox record as JSON (held in memory, rewritten on change) OUTBOX_LIMIT = 200 @@ -752,6 +756,7 @@ def __init__(self, node): self._untrusted_tokens = float(UNTRUSTED_BURST) self._untrusted_at: float | None = None self._untrusted_dry = False # logged (notice) that the bucket ran dry; verbose until a token is taken + self._sender_tokens: OrderedDict[bytes, tuple[float, float]] = OrderedDict() # sender -> (tokens, when) self._auto_wanted = threading.Condition() self._stop = threading.Event() self._wake = threading.Event() @@ -1073,9 +1078,9 @@ def received(self, message: Incoming) -> int | None: auto = sender in self.auto_fetch_from if auto: entry["auto"] = [{"state": "queued"} for _ in entry["links"]] - if not self.trusted(message.source) and not self._untrusted_token(): - _log(f"message {message_hash} from {sender} not kept: too many new messages from senders not in " - f"contacts, lxmf_accept_from or lxmf_auto_fetch_from", + if not self.trusted(message.source) and not self._untrusted_token(message.source): + _log(f"message {message_hash} from {sender} not kept: too many new messages from this sender, or from " + f"senders not in contacts, lxmf_accept_from or lxmf_auto_fetch_from", LOG_VERBOSE if self._untrusted_dry else LOG_NOTICE) self._untrusted_dry = True return None @@ -1120,11 +1125,19 @@ def _trusted_hex(self, sender) -> bool: except ValueError: return False - def _untrusted_token(self) -> bool: - """Take a token for a new record from a sender not trusted (UNTRUSTED_BURST, then one per - UNTRUSTED_INTERVAL, node-wide); False if there is none.""" + def _untrusted_token(self, source: bytes) -> bool: + """Take a token for a new record from source, a sender not trusted: first from its own allowance + (SENDER_BURST, then one per SENDER_INTERVAL), then from the node-wide bucket (UNTRUSTED_BURST, then one + per UNTRUSTED_INTERVAL); False, taking neither, if either has none.""" now = self.clock() with self._lock: + tokens, at = self._sender_tokens.pop(source, (float(SENDER_BURST), now)) + tokens = min(float(SENDER_BURST), tokens + max(0.0, now - at) / SENDER_INTERVAL) + self._sender_tokens[source] = (tokens, now) + while len(self._sender_tokens) > SENDER_ALLOWANCES: + self._sender_tokens.popitem(last=False) + if tokens < 1: + return False if self._untrusted_at is not None: self._untrusted_tokens = min(float(UNTRUSTED_BURST), self._untrusted_tokens + max(0.0, now - self._untrusted_at) / UNTRUSTED_INTERVAL) @@ -1132,6 +1145,7 @@ def _untrusted_token(self) -> bool: if self._untrusted_tokens < 1: return False self._untrusted_tokens -= 1 + self._sender_tokens[source] = (tokens - 1, now) self._untrusted_dry = False return True @@ -1955,7 +1969,8 @@ def _delivered(self, message): elif message.unverified_reason == self.LXMF.LXMessage.SOURCE_UNKNOWN and message.packed: # Unsigned as far as we can tell: anyone can send these claiming any source, so the queue # keeps what it holds (the newest is dropped) unless a trusted sender is claimed, a claimed - # sender gets only HOLD_PER_SENDER places, and path requests are rate limited + # sender gets only HOLD_PER_SENDER places (its newest taking the oldest's), and path requests + # are rate limited source, packed = bytes(message.source_hash), bytes(message.packed) if not self.feature.accepts(source): # received() would ignore it: holding it would only let forged sources fill the queue @@ -1965,8 +1980,8 @@ def _delivered(self, message): with self._lock: queued = self._hold(source, packed, message.method) if not queued: - _log(f"dropped a message from unknown sender {source.hex()}: already held, " - f"{HOLD_PER_SENDER} held from it, or {HOLD_LIMIT} held", LOG_VERBOSE) + _log(f"dropped a message from unknown sender {source.hex()}: already held, or {HOLD_LIMIT} held", + LOG_VERBOSE) return self._request_path(source) _log(f"holding a message from {source.hex()} until its identity is known", LOG_VERBOSE) @@ -1978,17 +1993,21 @@ def _delivered(self, message): def _hold(self, source: bytes, packed: bytes, method) -> bool: """Hold a message claiming source until its identity is known (lock held); False if it is dropped: the - same message is held, HOLD_PER_SENDER claiming source are, or HOLD_LIMIT are. A message claiming a - trusted sender (feature.trusted) then takes the place of the newest held for the untrusted sender - holding the most.""" + same message is held, or HOLD_LIMIT are. When HOLD_PER_SENDER claiming source are held, it takes the + place of the oldest of them, so forgeries sent first can't keep the real sender's message out. A message + claiming a trusted sender (feature.trusted) takes the place of the newest held for the untrusted sender + holding the most when the queue is full.""" if any(p == packed for _, p, _ in self._held): return False claims: dict[bytes, int] = {} for _, p, _ in self._held: claims[p[ID_LENGTH:2 * ID_LENGTH]] = claims.get(p[ID_LENGTH:2 * ID_LENGTH], 0) + 1 if claims.get(source, 0) >= HOLD_PER_SENDER: - return False - if len(self._held) >= HOLD_LIMIT: + oldest = next(item for item in self._held if item[1][ID_LENGTH:2 * ID_LENGTH] == source) + self._held = [item for item in self._held if item is not oldest] + _log(f"dropped the oldest held message from unknown sender {source.hex()} for a newer one", + LOG_VERBOSE) + elif len(self._held) >= HOLD_LIMIT: if not self.feature.trusted(source): return False others = [item for item in reversed(self._held) diff --git a/tests/security/test_l12r_untrusted_sender_allowance.py b/tests/security/test_l12r_untrusted_sender_allowance.py new file mode 100644 index 0000000..b771b7e --- /dev/null +++ b/tests/security/test_l12r_untrusted_sender_allowance.py @@ -0,0 +1,92 @@ +"""L12-R: after the L12 fix, new inbox messages from senders not trusted came only from a node-wide token bucket (32, +then one a minute), so a single identity sending one message with an rnfs link a minute kept it empty: no other +stranger's message was kept (0 of 144 in a day, simulated). + +Node B runs the lxmf feature on a stand-in network (tests/test_lxmf.py's FeatureTestCase), with a contact and a +settable clock. One sender floods; other strangers, each a new identity, send one message each. Theirs must be kept: +each sender first spends a small allowance of its own (SENDER_BURST, then one per SENDER_INTERVAL), and only then +the node-wide bucket. +""" +import os +import unittest + +from rnfs.features import lxmf +from rnfs.features.lxmf import Incoming, Link +from tests import test_lxmf +from tests.support import FakeClock + +ATTACKER = bytes([0xAA] * 16) +CONTACT = bytes([0xC0] * 16) +SENDER_BURST = 4 # rnfs.features.lxmf.SENDER_BURST: one sender's new records at once + + +def setUpModule(): + """Silence the node's log.""" + test_lxmf.setUpModule() + + +def tearDownModule(): + """Restore RNS's loglevel.""" + test_lxmf.tearDownModule() + + +class SenderAllowanceTest(test_lxmf.FeatureTestCase): + """Node B's feature with one LXMF contact and a clock the tests move.""" + + def setUp(self): + """B's feature, its contact, and the clock.""" + super().setUp() + self.feature = self.feature_node() + self.b.feature("contacts").add("carol", CONTACT.hex(), kind="lxmf") + self.clock = FakeClock(1000.0) + self.feature.clock = self.clock + self.uri = Link(self.cid).uri().encode() + + def send(self, source: bytes): + """A new message with an rnfs link from source; its inbox id, or None if it wasn't kept.""" + return self.feature.received(Incoming(source, os.urandom(32), 1.7e9, b"", self.uri, {}, "direct")) + + def test_one_sender_cannot_empty_the_bucket_at_once(self): + for _ in range(lxmf.UNTRUSTED_BURST): + self.send(ATTACKER) + kept = sum(self.send(os.urandom(16)) is not None for _ in range(10)) + # L12-R: fails until a sender spends an allowance of its own before the node-wide bucket + self.assertEqual(kept, 10, f"one sender's burst left room for {kept} of 10 other strangers' messages") + + def test_steady_sender_does_not_starve_strangers(self): + """The attacker sends one a minute, just as the bucket refills, for six hours; a new stranger writes every + 10 minutes, half a minute after the attacker.""" + for _ in range(lxmf.UNTRUSTED_BURST): + self.send(ATTACKER) + sent = kept = 0 + for minute in range(6 * 60): + if minute % 10 == 5: + self.clock.now += lxmf.UNTRUSTED_INTERVAL / 2 + sent += 1 + kept += self.send(os.urandom(16)) is not None + self.clock.now += lxmf.UNTRUSTED_INTERVAL / 2 + else: + self.clock.now += lxmf.UNTRUSTED_INTERVAL + self.send(ATTACKER) + # L12-R: fails until a sender spends an allowance of its own before the node-wide bucket + self.assertEqual(kept, sent, f"kept {kept} of {sent} strangers' messages") + self.assertIsNotNone(self.send(CONTACT)) # trusted senders aren't limited at all + + def test_allowance_per_sender(self): + """SENDER_BURST at once, then one per SENDER_INTERVAL; a message refused by it takes nothing from the bucket.""" + accepted = sum(self.send(ATTACKER) is not None for _ in range(SENDER_BURST + 3)) + # L12-R: fails until a sender spends an allowance of its own before the node-wide bucket + self.assertEqual(accepted, SENDER_BURST) + self.assertEqual(lxmf.SENDER_BURST, SENDER_BURST) + self.assertEqual(self.feature._untrusted_tokens, lxmf.UNTRUSTED_BURST - lxmf.SENDER_BURST) + self.clock.now += lxmf.SENDER_INTERVAL + self.assertIsNotNone(self.send(ATTACKER)) + self.assertIsNone(self.send(ATTACKER)) + # Senders' allowances are remembered for the most recent SENDER_ALLOWANCES senders only + for _ in range(2 * lxmf.SENDER_ALLOWANCES): + self.feature._untrusted_token(os.urandom(16)) + self.assertEqual(len(self.feature._sender_tokens), lxmf.SENDER_ALLOWANCES) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l13r_hold_replaces_oldest.py b/tests/security/test_l13r_hold_replaces_oldest.py new file mode 100644 index 0000000..79626de --- /dev/null +++ b/tests/security/test_l13r_hold_replaces_oldest.py @@ -0,0 +1,45 @@ +"""L13-R: after the L13 fix, a claimed sender got at most HOLD_PER_SENDER places in the queue of messages from +senders whose identity isn't known yet, and once forgeries claiming a sender held them all, that sender's real +message was dropped (LXMF has already told the sender it was delivered). + +An LxmfNetwork with a stand-in router and feature, as tests/security/test_l13_lxmf_hold_queue.py sets it up. Forged +messages claiming sender S fill S's places; S's real message must then take the place of the oldest of them. +""" +import os +import unittest + +from rnfs.features import lxmf +from tests import test_lxmf +from tests.security import test_l13_lxmf_hold_queue as l13 + + +def setUpModule(): + """Silence the node's log.""" + test_lxmf.setUpModule() + + +def tearDownModule(): + """Restore RNS's loglevel.""" + test_lxmf.tearDownModule() + + +class HoldReplaceTest(l13.HoldQueueTest): + """The hold queue of HoldQueueTest; only the test below runs here.""" + + def test_real_message_takes_the_place_of_the_oldest_forgery(self): + claimed = os.urandom(16) + for i in range(lxmf.HOLD_PER_SENDER): + self.deliver(claimed, b"forged%d" % i) + self.deliver(claimed, b"REAL") + # L13-R: fails until a claimed sender's newest message takes the place of its oldest held one + self.assertIn(b"REAL", self.tags(), "forgeries claiming the sender kept its real message out") + self.assertNotIn(b"forged0", self.tags()) + self.assertEqual(len(self.network._held), lxmf.HOLD_PER_SENDER) + + +for _name in [name for name in dir(l13.HoldQueueTest) if name.startswith("test_")]: + setattr(HoldReplaceTest, _name, None) # they run in their own module, not again here + + +if __name__ == "__main__": + unittest.main() From 7a67709db9380059568d6d1e8f06e67e14fb2739 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 092/107] Age the floor entries in test_floor_bounded, and space one sender's messages by SENDER_INTERVAL in the LXMF inbox tests --- tests/security/test_l4_name_rollback.py | 2 ++ tests/test_lxmf.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/security/test_l4_name_rollback.py b/tests/security/test_l4_name_rollback.py index 4e81ca6..b2d9ab0 100644 --- a/tests/security/test_l4_name_rollback.py +++ b/tests/security/test_l4_name_rollback.py @@ -120,7 +120,9 @@ def test_resolve_churn(self): self.assertTrue(result is None or result["sequence"] >= 5, f"gateway resolve went back from 5: {result}") def test_floor_bounded(self): + """Names resolved over FLOOR_KEEP_AGE ago are forgotten past FLOOR_FACTOR × name_table_size.""" for i in range(names.FLOOR_FACTOR * 3 + 5): + self.clock.now += names.FLOOR_KEEP_AGE self.network.announce(flood_name(i), encode_record(1, OLD), self.clock.now) self.manager.resolve(flood_name(i), persist=False) self.assertEqual(len(self.manager._floor), names.FLOOR_FACTOR * 3) diff --git a/tests/test_lxmf.py b/tests/test_lxmf.py index b2fbcdf..e762170 100644 --- a/tests/test_lxmf.py +++ b/tests/test_lxmf.py @@ -423,7 +423,9 @@ def test_untrusted_text_is_capped(self): def test_inbox_is_bounded_and_persists(self): feature = self.feature_node() feature.inbox.limit = 3 + feature.clock = FakeClock() for number in range(5): + feature.clock.now += lxmf.SENDER_INTERVAL # within the limits on senders not trusted (L12) feature.received(self.link_message(number=number)) self.assertEqual([e["id"] for e in feature.inbox_list()], [3, 4, 5]) feature.save() @@ -538,7 +540,7 @@ def test_one_sender_is_capped(self): feature.clock = FakeClock() junk = Link(self.cid).uri().encode() for i in range(lxmf.INBOX_PER_SENDER + 5): - feature.clock.now += lxmf.UNTRUSTED_INTERVAL # within the rate limit on senders not trusted (L12) + feature.clock.now += lxmf.SENDER_INTERVAL # within the rate limits on senders not trusted (L12) feature.received(message(OTHER, junk, number=i)) feature.received(self.link_message(source=bytes([0x54] * 16), number=1)) senders = [e["from"] for e in feature.inbox_list()] From 0a50020ad93e12fe1fa82defc8ec37b9658f2c84 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:33:32 +1030 Subject: [PATCH 093/107] Document keeper ranking by standing before the search, fair-share pool eviction, prompt floor saves and per-sender LXMF allowances --- CHANGELOG.md | 22 ++++++++++++++ README.md | 3 +- design.md | 59 +++++++++++++++++++++++++----------- specification.md | 79 +++++++++++++++++++++++++++++++----------------- 4 files changed, 116 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f3950..1cd1865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,11 +152,27 @@ DHT and names: each group, and asked a batch at a time. Another batch is asked while no answer reaches the sequence already known or only one keeper answered, at most 3 batches (2 on slow nodes, where a second `/name` is spent only if there's no known sequence to check the first answer against). +- Keeper queries can no longer be steered by identities that only answer `/find`. Keepers are ranked on the + routing table as it stood when the query started, so nodes the lookup meets don't rank above keepers recorded + here; the search ends early only once two established servers (in the table for `provider_seniority` and + healthy) listed keepers, and records stored here count as a source only if an established publisher stored + one; each batch first takes keepers listed by a source no earlier pick was listed by; and when the node knows + no sequence for the name, only answers from established keepers settle it. Unknown keepers get a budget of + `alpha` path requests of their own, which `/find` replies naming unknown nodes can no longer use up. On a slow + node a query is still at most two `/name` requests; a fast node may ask up to 9 keepers, and its lookup may + run to completion, where established servers are scarce (a young network). +- A full newcomer pool in the provider store evicts from the publisher holding the most pooled records instead + of at random, so a flood of new identities leaves an honest newcomer about a fair share of the pool (16 of its + 100 records against 600 identities, where it used to keep 1). A newcomer whose last record the pool evicted + keeps its first-seen time (at most `max_records / 100` of them), so it still ages into a full share. - Gateway and LXMF auto-fetch name resolves can no longer be rolled back by flooding announces of new names and replaying an old one: an announce of a name the node isn't resolving or keeping only takes the table place of another name that was only heard, and the highest sequence of each name resolved this way is saved in `state/name-floor.json` (up to 4 × `name_table_size` names), so no older record is accepted after table churn or a restart. +- The name floor is saved by the resolve that raises it (at most every 5 s), where a crash up to a minute after a + resolve used to lose it, and names resolved in the last 7 days are kept past 4 × `name_table_size`, up to + 8 ×, so that many resolves of other names no longer let a replayed old record of a recent one back in. - The specification no longer claims that a node's names can't be linked to it or to each other: the node keeps its own names and provides their targets under its node ID, so its names are linked to it, as providing content reveals the provider in IPFS. Behaviour is unchanged. @@ -203,6 +219,12 @@ LXMF: message not holding pins out of the inbox, including contacts' messages and auto-fetches that had failed. - New inbox messages from senders not trusted are rate limited node-wide: 32 at once, then one a minute. The README now recommends setting `lxmf_accept_from`. +- Each sender not trusted also has an allowance of its own, 4 new inbox messages at once, then one per 15 + minutes, spent before the node-wide limit, so one sender writing once a minute can no longer keep every + other stranger's messages out. +- A message from a sender whose identity isn't known yet, arriving when 8 held messages already claim that + sender, now takes the place of the oldest of them instead of being dropped, so forgeries sent first can't + keep the sender's real message out. - Messages from senders whose identity isn't known yet: at most 8 of the 32 held may claim one sender, a held message is dropped 30 s after an unanswered path request instead of waiting 2 minutes, a message claiming a trusted sender takes the place of one held for an unknown sender, and trusted senders get path requests diff --git a/README.md b/README.md index da32d29..c7bc1bc 100644 --- a/README.md +++ b/README.md @@ -639,7 +639,8 @@ The inbox keeps at most 500 messages (4 MiB in all), and at most 50 from any one Senders you've named (in `lxmf_accept_from`, in `lxmf_auto_fetch_from`, or as an LXMF contact with `rnfs contacts add NICK ADDRESS --kind lxmf`) are trusted. Anyone can make new LXMF addresses for free, so messages from everyone else can only push out each other's, and at most 32 new ones are kept in a burst, -then one a minute; while a flood lasts, a stranger's message may not be kept. If you only expect links from +then one a minute, and at most 4 from any one of them at once, then one per 15 minutes (so one sender can't use +up the rest's); while a flood from many addresses lasts, a stranger's message may not be kept. If you only expect links from people you know, set `lxmf_accept_from` to their addresses: messages from anyone else are then ignored, and nobody can crowd them out. diff --git a/design.md b/design.md index 164f607..ba1eb18 100644 --- a/design.md +++ b/design.md @@ -157,7 +157,8 @@ packaging/ requirements.lock (hash-pinned runtime dependencies), upda `heard(node_id) -> bool` (proven server: announce in server mode, or answered `/find`), `failed(node_id) -> bool` (True if dropped), `remove(node_id)`, `ban(node_id)`, `is_banned(node_id)`, `closest(key, count=K, for_reply=False)`, `seniority(node_id) -> float | None` (seconds in the table), `is_senior(node_id, min_age)`, `standing(node_id, -min_age) -> int` (0 senior and healthy, 1 healthy, 2 stale, 3 absent: ranks keepers), `contains(node_id)`, +min_age) -> int` (0 senior and healthy, 1 healthy, 2 stale, 3 absent: ranks keepers), `standings(min_age) -> +dict` (every node's standing now: the keeper query's snapshot from before its lookup), `contains(node_id)`, `node_count()`. `providers.py` — `ProviderStore(max_records, state_path, ttl=PROVIDER_TTL, clock=time.time, rng=random, @@ -169,10 +170,14 @@ non-members), `for_reply(key, exclude=lambda id: False) -> list[bytes]`, `expire that (at least 10) before, the node's own (`own_id`) excepted. A publisher at its limit has its expired records swept at most once per batch, and not at all before `sweep_after` (the earliest its oldest record can expire, from its last sweep), so each further key of a refused `/provide` costs one check; a key keeps at most 1000 publishers: a newcomer to a full key evicts a random -one, never one of the `K/2` oldest records. A tenth of the store is a newcomer pool with random eviction: the -pooled records sit in a list for O(1) random picks, and the newest publisher with records outside the pool comes +one, never one of the `K/2` oldest records. A tenth of the store is a newcomer pool evicting by fair share: each +publisher's pooled keys sit in an ordered dict (oldest pooled first), and publishers in lists by how many they +hold (`_holders`, with each one's index, and `_most_pooled`), so a full pool picks one of the biggest holders at +random in O(1); one already holding the most is refused. The newest publisher with records outside the pool comes from a heap of (first seen, arrival) with stale entries dropped lazily. First-seen times of departed publishers -(seen for `seniority`) are kept for a TTL, at most `max_records` // 10, in departure order. Times later than now +(seen for `seniority`) are kept for a TTL, at most `max_records` // 10, in departure order; those of younger +publishers whose last record the pool evicted (`_evicted`) for a TTL too, at most `pool_size` // 10 (at least +10), forgetting the one first seen most recently when full (an O(n) scan, only on such evictions). Times later than now (a clock corrected backwards) are clamped to now at load and at each `expire()`. `lookup.py` — network access is injected: @@ -181,7 +186,8 @@ from a heap of (first seen, arrival) with stale entries dropped lazily. First-se `find_closest() -> list[bytes]` and `find_providers(on_provider, should_stop) -> list[bytes]`. `is_known(id)` says whether RNS knows its identity; at most `alpha` unknown IDs are tried per lookup (`allow_unknown(id)`, also used by the fetcher for provider IDs). `listing_count()` is how many nodes have answered with at least one -provider, so the keeper search can wait for providers listed by more than one source. +provider, `listing_nodes()` which, and `listers(provider)` which listed a given provider, so the keeper search +can tell who vouches for its candidates. ## Network layer interfaces @@ -405,9 +411,9 @@ once (notice) that downloads and the fallback are off. On a propagation-node lin Resource over 1 MB (4 × `DELIVERY_LIMIT_KB`) or of more than one segment and tears the link down, and asks for at most 64 messages per download. `LxmfFeature.trusted()` is true for senders in `lxmf_accept_from` or `lxmf_auto_fetch_from` and LXMF contacts (`ContactsFeature.has(address, "lxmf")`, an index kept beside the -address book). Messages from unknown senders are held (`_hold`: at most 32, 8 per claimed sender; further ones -dropped, and none from senders `accepts()` would ignore, but a trusted sender's takes the place of the newest -held for the untrusted sender holding the most) while their identity is requested: one path request per sender +address book). Messages from unknown senders are held (`_hold`: at most 32, 8 per claimed sender, a ninth +claiming one taking the place of its oldest; further ones dropped, and none from senders `accepts()` would +ignore, but a trusted sender's takes the place of the newest held for the untrusted sender holding the most) while their identity is requested: one path request per sender per 2 minutes, from a node-wide token bucket (burst 3, then one per 10 s), trusted senders' held messages retried first. A held message is dropped 30 s (`HOLD_PATH_WAIT`) after its sender's path request, or its own arrival if later, with no identity known, and after 2 minutes in any case. A held message stays in the queue @@ -415,8 +421,10 @@ while it is retried, so it still counts as a duplicate and against the limit. Th most 500 records and 4 MiB as JSON, 50 per sender not in `lxmf_auto_fetch_from`, 16 KiB per record (links beyond it dropped, then text cut), and never evicts a record holding auto-fetched pins for one that holds none; `add(reserved=)` keeps trusted senders' records from being evicted except for each other's (untrusted ones go -first), so free identities can't push them out, and untrusted senders' new records come from a node-wide token -bucket (`UNTRUSTED_BURST` 32, then one per `UNTRUSTED_INTERVAL` 60 s, on `LxmfFeature.clock`). The pin's +first), so free identities can't push them out, and untrusted senders' new records come from a per-sender +allowance (`SENDER_BURST` 4, then one per `SENDER_INTERVAL` 15 min; `_sender_tokens`, the last +`SENDER_ALLOWANCES` 256 senders) and then a node-wide token bucket (`UNTRUSTED_BURST` 32, then one per +`UNTRUSTED_INTERVAL` 60 s), both on `LxmfFeature.clock`; a message refused by either takes from neither. The pin's "pinned" state is recorded in the same locked section that pins. Auto-fetched `name/` links resolve with `persist=False`; each auto-fetch runs under a `fetcher.observing` observer cancelled when the node stops or the message is removed, and links still "queued" in the inbox are queued again at `start()` @@ -479,8 +487,10 @@ Under `/state/`, JSON written atomically from a temp file synced to di list); `providers.json` (provider records); `published.json` (publisher: last rounds, earlier rounds' nodes, pending withdrawals); `fetched.json` (targets fetched for the user, for `roots`); `remote_pins.json` (remote pins accepted and not yet fetched, `[[source, cid], ...]` in order); `names.json` (sequences, name -table); `name-floor.json` (highest sequence of names resolved for the gateway or auto-fetch, at most -4 × `name_table_size`, saved by the re-announce loop when changed); `invalid.json` (invalid roots); `shared_folder.json`; `jobs.json` (results keep at most 100 warnings +table); `name-floor.json` (`[[name, sequence, resolved], ...]`: highest sequence of names resolved for the +gateway or auto-fetch and when, at most 4 × `name_table_size`, or 8 × for names resolved within 7 days; saved by +a resolve raising it, at most every 5 s, and by the re-announce loop when changed; entries without the time load +as resolved long ago); `invalid.json` (invalid roots); `shared_folder.json`; `jobs.json` (results keep at most 100 warnings plus `warnings_total`; older results are capped at load); `keepers.json` (records re-verified at load); `follow.json`; `contacts.json`; `lxmf_inbox.json` (500 messages, 4 MiB, 50 per non-auto-fetch sender, 16 KiB per record) and `lxmf_outbox.json` (200; unfinished messages are marked failed at a restart), both UTF-8 JSON with @@ -613,11 +623,19 @@ Everything in the specification is implemented, with these simplifications and a sends nothing for a `None` response, so "not kept" is `[]`. Kept records are offered to `NameManager` at startup and on every resolve (the rollback floor). Kept names without a record are retried round robin, at most 64 per `announce_interval`, continuing after the last one asked. `_store` refuses a record below the - sequence `NameManager` holds for the name. `ask_keepers` searches for 3 × alpha candidates from two sources - (`Lookup.listing_count`, plus one for local records), ranks them with `RoutingTable.standing` and a random - tie-break (`rank_keepers`), and asks batches of alpha until an answer reaches the highest known sequence - (`NameManager.record` or the kept record) from two keepers, at most `keeper_rounds()` batches (3; 2 on slow - nodes, where one current answer is enough once a sequence is known). + sequence `NameManager` holds for the name. `ask_keepers` takes `RoutingTable.standings` before its lookup and + uses the worse of that and the current standing (`_standing`), so nodes the lookup adds to the table don't + gain rank; "established" is standing 0. It searches for 3 × alpha candidates from two established sources + (`Lookup.listing_nodes`, plus this node if an established publisher stored a record here), ranks them + (`rank_keepers(candidates, before)`: standing, then a random tie-break), and asks batches of alpha: each + batch first takes, in rank order, candidates with a source no earlier pick had (`vouched`: established + listers from `Lookup.listers`, this node for local records, else the shared `_UNVOUCHED`), then fills in + rank order. Unknown keepers come from a budget of alpha of the query's own (`may_ask`). Batches go on until + an answer reaches the highest known sequence (`NameManager.record` or the kept record) from two keepers + (two established ones when no sequence is known), at most `keeper_rounds()` batches (3; 2 on slow nodes, + where one current answer is enough once a sequence is known). On a slow node that is still at most two + `/name`; a fast node with no known sequence and no established keepers asks all 3 batches (up to 9), and the + lookup runs to completion unless two established nodes list keepers. - **Names.** A key imported, or found without an entry in `names.json`, is marked `imported`: its first publish offers kept records, makes a path request and queries keepers before choosing a sequence. `publish()` pins the new target, saves `names.json` (the commit point; a failed save rolls the record back and releases the @@ -633,7 +651,12 @@ Everything in the specification is implemented, with these simplifications and a message per sender listing them), so a later start keeps them too. The name table marks entries that were only heard (`_heard_only`, its own LRU, so finding one to evict is O(1)); an announce with no waiting resolve, of a name without a kept record, may only evict one of those. A resolve with - `persist=False` (gateway, LXMF auto-fetch) raises the name's floor (`_floor`), which `_offer` checks. + `persist=False` (gateway, LXMF auto-fetch) raises the name's floor (`_floor`: name -> (sequence, wall time + last resolved), in resolve order), which `_offer` checks. A raise (or a new name) saves `name-floor.json` + at once if the last save was `FLOOR_SAVE_INTERVAL` (5 s) ago, else from a one-shot `threading.Timer`; + renewing only the time marks it for the next save after an hour. `_trim_floor` drops the least recently + resolved past `FLOOR_FACTOR` × `name_table_size`, but stops at one resolved within `FLOOR_KEEP_AGE` (7 days) + until the floor holds `FLOOR_HARD_FACTOR` (2) times that. - **Follow** isn't built on jobs (job pins use source `user`); `pin add --follow` doesn't exist. Checks use the record held unless nothing was heard for 2 × `announce_interval`; a failed check (any exception) is retried after 300 s, doubling up to `announce_interval`, and a failed fetch is retried with the record held diff --git a/specification.md b/specification.md index d5b59ea..3dd8033 100644 --- a/specification.md +++ b/specification.md @@ -478,7 +478,8 @@ big-endian integer; "closest" means smallest distance. **Lookups.** Query the `alpha` closest known nodes not yet queried with `/find`, merge the nodes they return, and repeat. - A returned node or provider ID with no known identity (`Identity.recall` fails) costs a path request; at most - `alpha` such IDs are tried per lookup. + `alpha` such IDs are tried per lookup. (A keeper query asks unknown keepers from a budget of its own, also + `alpha`, so nodes named in `/find` replies can't use it up, section 9.) - Nodes that fail to answer are dropped from the lookup and counted in the routing table. - Every lookup has a time budget, `lookup_timeout`. - **Termination.** As in IPFS, a lookup is complete once the `beta` closest nodes it knows of have all @@ -542,13 +543,18 @@ and repeat. - A node keeps at most `max_records`, a tenth of them in a *newcomer pool*. When the rest is full, a publisher first seen before the most recent one there evicts a record of that most recent publisher (the one it stored least recently), so a flood of new identities can't displace older publishers. Any other - publisher (one not seen before, or the most recent one) stores into the pool, evicting a pooled record - chosen at random when the pool is full, but never one of its own: so a new publisher can always store - something, and a flood churns only the pool. A pooled record leaves the pool when it is republished while - the rest has room. The node's own records are always accepted. + publisher (one not seen before, or the most recent one) stores into the pool. A full pool evicts the + record pooled first of a publisher holding the most pooled records (one chosen at random if several hold + as many); a publisher that already holds as many as any other is refused instead, so a new publisher can + always store something, a flood churns only the pool, and the pool is shared out evenly between + publishers rather than emptied of a newcomer's records. A pooled record leaves the pool when it is + republished while the rest has room. The node's own records are always accepted. - First-seen times are persisted, and kept for `PROVIDER_TTL` after a publisher's last record is gone (rnfs: for publishers seen for at least `provider_seniority`, at most `max_records / 10` of them, the - oldest departure forgotten first), so a publisher that was away for a while isn't new again. + oldest departure forgotten first), so a publisher that was away for a while isn't new again. A newcomer + whose last record a full pool evicted keeps its first-seen time too, so a flood can't keep it new forever + (rnfs: at most `max_records / 100` such newcomers, at least 10; when more, the one first seen most recently + is forgotten). - A node SHOULD limit how many records one publisher holds, its own excepted (rnfs: `max_records / 100`, at least 10, once the publisher has been seen for `provider_seniority`, and a tenth of that, at least 10, before), so one identity can't fill the store and a fresh one gets little. @@ -755,20 +761,29 @@ the name through the network and asks keepers (below), since the key's earlier r - To refresh, call `Transport.request_path(name)`, wait up to `path_timeout`, then read `Identity.recall_app_data(name)`. - If the path request heard nothing within `path_timeout` (or always, with `keeper_query = always`), ask - keepers (below). Storing a provider record is free, so neither a record's age nor the order a reply lists it - in may decide which keepers are asked: + keepers (below). Storing a provider record is free, and so is answering `/find`, so neither a record's age, + nor the order a reply lists it in, nor how many nodes list it may decide which keepers are asked. Standing + is read from the routing table as it was when the query started: a node the lookup meets doesn't gain rank + from answering it, and one that fails a query meanwhile loses rank. An *established* node is one that was + in the table for over `provider_seniority` then, and hasn't failed a query since last heard nor been silent + for 2 × `announce_interval`. - Find providers of the name's key (section 6). Providers recorded locally are candidates, never a reason - to skip the search, which runs until 3 × `alpha` are known and more than one source listed some (this - node's own records count as one source), or until the lookup is complete. - - Rank the candidates by routing-table seniority, as providers are (section 6): servers in the routing table - for over `provider_seniority` that haven't failed a query since last heard nor been silent for - 2 × `announce_interval` first, then other such servers, then servers that failed a query or went silent - (an owner that has left), then IDs not in the table; at random within each group. + to skip the search, which runs until 3 × `alpha` are known and two established sources listed some, or + until the lookup is complete. A source is a node whose `/find` answer listed providers, or this node if + it holds a record stored by an established publisher. + - Rank the candidates by routing-table seniority, as providers are (section 6): established servers first, + then other servers in the table, then servers that failed a query or went silent (an owner that has + left), then IDs not in the table; at random within each group. - Ask them with `/name`, a batch of `alpha` at a time, and offer every verifying record to the sequence - rules, so the highest valid sequence wins. Ask another batch while no answer reaches the highest sequence - already known for the name (from an announce, an earlier resolve or a kept record), or while fewer than two - keepers have answered; at most 3 batches. A slow node (`alpha` 1) asks at most 2 batches, and asks the - second for want of a second answer only when it knows no sequence to compare the first against. + rules, so the highest valid sequence wins. Each batch takes first, in rank order, candidates listed by a + source no earlier candidate of the query was listed by (this node holding a record, an established node, + or, counted as one source, none of these), then the rest in rank order. A candidate whose identity isn't + known (each costs a path request) is asked within a budget of `alpha` such candidates per query, apart + from the lookup's (section 6). Ask another batch while no answer reaches the highest sequence already + known for the name (from an announce, an earlier resolve or a kept record), or while fewer than two + keepers have answered; when no sequence is known, only established keepers' answers count toward those + two. At most 3 batches. A slow node (`alpha` 1) asks at most 2 batches, and asks the second for want of a + second answer only when it knows no sequence to compare the first against. A keeper query (a DHT lookup plus a few requests) costs roughly ten to twenty times the airtime of a resolve a path response answers, so `always` SHOULD NOT be used on slow links. @@ -787,8 +802,9 @@ what prevent rollback. - For names resolved without being persisted (gateway, auto-fetch), the node SHOULD persist the highest sequence it returned, as a floor below which no record of that name is accepted, so such a resolve never goes back after table churn or a restart. The record itself isn't persisted. (rnfs: up to - 4 × `name_table_size` names, least recently resolved forgotten first, saved at least every minute and at - shutdown.) + 4 × `name_table_size` names, least recently resolved forgotten first, except that names resolved in the + last 7 days are kept up to 8 × `name_table_size`; saved by a resolve that raises the floor, at most every + 5 s, otherwise at least every minute and at shutdown.) - Records from keepers are subject to the same persisted-sequence and no-rollback rules as announces. - A record this node keeps (below) is a floor for resolving that name: it MUST be offered to the sequence rules at startup and on every resolve, so an older validly signed announce is never accepted for a kept @@ -874,9 +890,9 @@ as is cheap. Rules live in the sections referenced. | Decompression and oversized-response bombs | Compressed Resources refused; response sizes capped; multi-segment Resources refused on `rnfs.dht` links and on links to LXMF propagation nodes; `rnfs.dht` responses' msgpack headers checked before RNS decodes them (3, 7, 13) | | Reading another node's download history | Only provided content is served (7). Under the default `roots`, complete cached downloads are provided; `pinned` keeps them private | | Name rollback or forgery | Self-certifying names; RNS-verified signatures; persisted highest sequence (9) | -| Forged or withheld kept names | Records verified against the name's key; highest persisted sequence; several keepers asked (9) | +| Forged or withheld kept names | Records verified against the name's key; highest persisted sequence; several keepers asked, ranked by routing-table standing from before the query and drawn from different sources; answers from established keepers only settle a name with no known sequence (9) | | Tampered or spliced sealed files | Per-segment HMAC bound to the header, index and final flag, checked before decrypting (5) | -| Unsolicited LXMF links, inbox flooding | Strict field parsing, size caps per link, per message and for the whole inbox; at most 50 kept messages per untrusted sender; auto-fetched pins never pushed out by others' messages; signature-validated senders only; rate-limited path requests for unknown senders; `lxmf_accept_from`, per-sender `lxmf_auto_fetch_quota` (13) | +| Unsolicited LXMF links, inbox flooding | Strict field parsing, size caps per link, per message and for the whole inbox; at most 50 kept messages per untrusted sender; new ones from untrusted senders rate limited per sender, then node-wide; auto-fetched pins never pushed out by others' messages; signature-validated senders only; rate-limited path requests for unknown senders; `lxmf_accept_from`, per-sender `lxmf_auto_fetch_quota` (13) | | Uploads exhausting a shared channel | `upload_budget` (charged for bytes sent, at most half of it per requester in flight), interface rules and quiet hours, applied to `/blocks` only (3) | | Names and paths chosen by others reaching the local user | Default output names only if printable, not hidden, not starting with `-` and one valid file name, else the CID; non-printable characters escaped in output and logs (11) | | Republishing and retries draining airtime | Small `keep_max_names`; slower, jittered republishing of name keys; paced lookups on slow nodes; follow backoff (6, 9) | @@ -916,7 +932,12 @@ RNS's own `blackhole_identity` is not used: it is instance-wide and persistent. make that key hard to find (a Sybil attack); it can't forge data. `get --from ` bypasses the DHT when a provider is known. - Patient attackers that keep identities alive for days defeat the preference for old records and long-known - providers. + providers. Such identities, established in a resolver's routing table near a name's keeper key, can also + answer its keeper query with a replayed older record of the name, unless the resolver knows a newer + sequence or reaches a keeper they don't list (one recorded locally, or listed by another established node). +- A flood of new identities with more of them than the newcomer pool has records leaves each, an honest + newcomer included, about one pooled record at a time; a newcomer's first-seen time outlasts that only + while fewer than the bounded number of newcomers first seen before it were evicted too. - Bans are evaded by switching identity; they only make an attacker pay for a new one. - A provider can lower its own measured rate, and so slow a fetch from it; on slow nodes, which use one provider at a time, this can delay a fetch for a long time. @@ -1295,9 +1316,10 @@ unreserved, pct-encoded: as in RFC 3986 - A message is accepted only once LXMF has validated its signature with the sender's identity. A message from a sender whose identity isn't known yet is held while a path request fetches it, then validated: at most 32, at most 8 claiming one sender, for up to 2 minutes, and only 30 seconds after its path request was sent - (or after it arrived, if later) if no answer has made the identity known. When 32 are held, a further one - is dropped, so held ones are not pushed out, unless it claims a trusted sender: then it takes the place of - the newest message held for the untrusted sender holding the most. The source of such a message is + (or after it arrived, if later) if no answer has made the identity known. A ninth message claiming one + sender takes the place of the oldest of its 8, so forgeries sent first can't keep the sender's real message + out. When 32 are held, a further one is dropped, so held ones are not pushed out, unless it claims a trusted + sender: then it takes the place of the newest message held for the untrusted sender holding the most. The source of such a message is unauthenticated until its identity is known, so path requests for held senders are sent at most once per sender per 2 minutes, and at most 3 at once, then one per 10 seconds node-wide, trusted senders first. - If `lxmf_accept_from` is not empty, messages from other senders (except those in `lxmf_auto_fetch_from`) @@ -1312,8 +1334,9 @@ unreserved, pct-encoded: as in RFC 3986 pins, a new message without any is not kept (and one with pins pushes out the oldest). A trusted sender's message is dropped only to make room for another trusted sender's, and only once no untrusted sender's message without pins is left; a new message from an untrusted sender that would need one dropped is not - kept. New messages from untrusted senders are also rate limited node-wide: 32 at once, then one per minute; - others are not kept. Each message's record is at most 16 KiB as JSON: links beyond that are dropped (the + kept. New messages from untrusted senders are also rate limited: each sender has an allowance of 4 at once, + then one per 15 minutes, and within it messages are taken from a node-wide bucket of 32 at once, then one + per minute; others are not kept. So one sender can't keep the node-wide bucket empty for everyone else. Each message's record is at most 16 KiB as JSON: links beyond that are dropped (the first is always kept), then text is cut. - **Auto-fetch.** Links from a sender in `lxmf_auto_fetch_from` are fetched completely and pinned with source `lxmf:`, one fetch at a time, taking senders in turn. At most 64 links wait: when that many From 2f550e5ed7c5f0b6c62fdddd238ebf6af86a1048 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:44:22 +1030 Subject: [PATCH 094/107] Say that the parser fuzz tests run only by hand, since CI doesn't install hypothesis --- tests/security/test_fuzz_parsers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/security/test_fuzz_parsers.py b/tests/security/test_fuzz_parsers.py index f9f3e6a..8762026 100644 --- a/tests/security/test_fuzz_parsers.py +++ b/tests/security/test_fuzz_parsers.py @@ -7,8 +7,9 @@ for every accepted b. Needs hypothesis, which is a development tool and not one of rnfs's dependencies: without it every test here -is skipped. The default run is small and deterministic (derandomized); set RNFS_FUZZ_EXAMPLES (e.g. 3000) -for a deeper, randomized run. +is skipped, which includes CI (it installs only the locked dependencies). Run it by hand in an environment +that has hypothesis: `python -m unittest tests.security.test_fuzz_parsers`. The default run is small and +deterministic (derandomized); set RNFS_FUZZ_EXAMPLES (e.g. 3000) for a deeper, randomized run. """ import io import os From 35ef957e58de8eec12c42678750d54e7ff0c4897 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:56:47 +1030 Subject: [PATCH 095/107] Give the fake ACL backend create_private_dir and the page's fake CLI status, expect a symlinked identity to be refused, and count the page's status call --- tests/security/_pages_helpers.py | 5 +++++ tests/security/test_h2_display_page_pins.py | 16 +++++++++------- tests/security/test_i2_identity_symlink.py | 18 ++++++++---------- tests/security/test_l7_windows_acl.py | 19 +++++++++++++++++-- tests/test_identity.py | 9 +++++---- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/tests/security/_pages_helpers.py b/tests/security/_pages_helpers.py index eb718fd..91bbabf 100644 --- a/tests/security/_pages_helpers.py +++ b/tests/security/_pages_helpers.py @@ -155,6 +155,11 @@ def run(argv, *args, **kwargs): return _Completed(0, (node.pin_add(words[2])["cid"] + "\n").encode("utf-8")) if words[0] == "cat": return cat(words[1], words[2:]) + if words == ["status"]: + from rnfs.cli import print_fields + with contextlib.redirect_stdout(io.StringIO()) as printed: + print_fields(node.status()) + return _Completed(0, printed.getvalue().encode("utf-8")) if words[:3] == ["pin", "ls", "--json"]: return _Completed(0, json.dumps(node.pin_ls(*words[3:4])).encode("utf-8")) except ValueError as error: # e.g. " is not a directory", which the page keys off diff --git a/tests/security/test_h2_display_page_pins.py b/tests/security/test_h2_display_page_pins.py index 503b2ba..165475b 100644 --- a/tests/security/test_h2_display_page_pins.py +++ b/tests/security/test_h2_display_page_pins.py @@ -135,11 +135,12 @@ class OperatorPinTest(PageTestCase): """CIDs the operator pinned on the victim itself.""" def test_operator_pin_is_shown_without_fetching_or_pinning(self): - """A complete user pin is shown, read with a bounded raw cat; no pin add, ls or other command runs.""" + """A complete user pin is shown, read with a bounded raw cat; no pin add, ls or other command runs (status + says whether the node is in private mode).""" cid = self.victim.add_stream(io.BytesIO(b"the operator's notes\n"))["cid"] output, calls = self.view(cid) self.assertIn("the operator's notes", output) - self.assertEqual(calls, [["pin", "ls", "--json", cid], ["stat", cid], + self.assertEqual(calls, [["pin", "ls", "--json", cid], ["status"], ["stat", cid], ["cat", cid, "--raw", "--length", str(self.page("MAX_SIZE"))]]) def test_sealed_operator_pin_is_not_shown(self): @@ -151,7 +152,8 @@ def test_sealed_operator_pin_is_not_shown(self): self.assertIn("sealed", output) def test_folder_pin_is_refused_as_a_folder(self): - """A pinned directory isn't a file to show, and nothing more than its pin listing is read.""" + """A pinned directory isn't a file to show, and nothing more than its pin listing (and the node's + status) is read.""" folder = os.path.join(self.tmp.name, "shared") os.makedirs(folder) with open(os.path.join(folder, "a.txt"), "wb") as f: @@ -159,7 +161,7 @@ def test_folder_pin_is_refused_as_a_folder(self): cid = self.victim.add_path(folder)["cid"] output, calls = self.view(cid) self.assertIn("is a folder", output) - self.assertEqual(calls, [["pin", "ls", "--json", cid]]) + self.assertEqual(calls, [["pin", "ls", "--json", cid], ["status"]]) class AllowlistTest(PageTestCase): @@ -211,15 +213,15 @@ def test_page_deadline_is_shared_by_its_commands(self): clock = [1000.0] def slow_run(argv, *args, **kwargs): - """Record the timeout the page passes, and let 50 seconds pass for each command.""" + """Record the timeout the page passes, and let 30 seconds pass for each command.""" timeouts.append(kwargs["timeout"]) - clock[0] += 50 + clock[0] += 30 return run(argv, *args, **kwargs) with mock.patch.object(time, "monotonic", lambda: clock[0]): helpers.exec_display_page(slow_run, cid, self.data_dir) timeout = self.page("TIMEOUT") - self.assertEqual(timeouts, [timeout, timeout - 50, timeout - 100]) + self.assertEqual(timeouts, [timeout, timeout - 30, timeout - 60, timeout - 90]) class LockAndRateLimitTest(PageTestCase): diff --git a/tests/security/test_i2_identity_symlink.py b/tests/security/test_i2_identity_symlink.py index 26d2705..7e55556 100644 --- a/tests/security/test_i2_identity_symlink.py +++ b/tests/security/test_i2_identity_symlink.py @@ -1,8 +1,8 @@ """I2 (fix): the identity file's permission tightening followed symlinks, so a symlink put at the identity path got -another file's permissions changed. Now a symlink there is left alone (with a warning) and a regular file is changed -through a descriptor opened with O_NOFOLLOW. +another file's permissions changed. Now a symlink there stops the node (I2-R1: it used to be loaded with a warning) +and a regular file is checked and changed through a descriptor opened with O_NOFOLLOW, which the key is read from. -load_or_create_identity is run on a symlink to a loose file holding a key (it still loads, the target's mode is +load_or_create_identity is run on a symlink to a loose file holding a key (refused, the target's mode is unchanged), on a loose regular file (still tightened), and with the path swapped for a symlink between the lstat and the open (the open refuses it, nothing is changed). """ @@ -40,11 +40,9 @@ def setUp(self): def test_symlink_target_is_not_changed(self): os.symlink(self.other, self.path) - with mock.patch.object(identity_module, "log") as log: - loaded = load_or_create_identity(self.path) - self.assertEqual(loaded.hash, self.identity.hash) # still the user's choice to link it + with self.assertRaisesRegex(identity_module.IdentityFileError, "is a symlink"): + load_or_create_identity(self.path) self.assertEqual(mode_of(self.other), 0o644, "the permission fix followed the symlink") - self.assertIn("is a symlink", log.call_args.args[0]) def test_loose_regular_file_is_still_tightened(self): os.rename(self.other, self.path) @@ -55,10 +53,10 @@ def test_loose_regular_file_is_still_tightened(self): def test_symlink_swapped_in_after_the_check_is_not_followed(self): os.symlink(self.other, self.path) regular = os.stat(self.other) # what lstat would have seen before the swap - with mock.patch("os.lstat", return_value=regular), mock.patch.object(identity_module, "log") as log: - identity_module._restrict_permissions(self.path) + with mock.patch("os.lstat", return_value=regular), \ + self.assertRaisesRegex(identity_module.IdentityFileError, "can't use the identity file"): + load_or_create_identity(self.path) self.assertEqual(mode_of(self.other), 0o644, "the permission fix followed the symlink") - self.assertIn("could not restrict", log.call_args.args[0]) if __name__ == "__main__": diff --git a/tests/security/test_l7_windows_acl.py b/tests/security/test_l7_windows_acl.py index 4009a77..6a5a4b1 100644 --- a/tests/security/test_l7_windows_acl.py +++ b/tests/security/test_l7_windows_acl.py @@ -43,12 +43,14 @@ def allow(sid: str, mask: int = FULL, flags: int = INHERITED) -> Ace: class FakeAcl: - """A winacl backend: a DACL per file, set_private's calls, and `inherited` for files never set.""" + """A winacl backend: a DACL per file, set_private's and create_private_dir's calls, and `inherited` for files + never set.""" def __init__(self, inherited: Dacl = SHARED): self.inherited = inherited self.dacls: dict[tuple[int, int], Dacl] = {} self.set_calls: list[tuple[str, bool]] = [] + self.created: list[str] = [] self.read_fds: list[int | None] = [] @staticmethod @@ -68,6 +70,16 @@ def set_private(self, path: str, directory: bool): self.set_calls.append((path, directory)) self.dacls[self.key(path)] = Dacl(USER, winacl.parse_acl(winacl.private_acl(USER, directory))) + def create_private_dir(self, path: str) -> bool: + """mkdir with the private DACL from the start; False if path exists.""" + try: + os.mkdir(path) + except FileExistsError: + return False + self.created.append(path) + self.dacls[self.key(path)] = Dacl(USER, winacl.parse_acl(winacl.private_acl(USER, True))) + return True + def set(self, path: str, dacl: Dacl): """Give path dacl, as someone else (or an older rnfs) left it.""" self.dacls[self.key(path)] = dacl @@ -175,7 +187,10 @@ def messages(self) -> str: def test_new_dir_gets_a_protected_dacl(self): node_module._make_private_dirs(Config(rnfs_dir=self.root)) - self.assertEqual(self.acl.set_calls, [(self.root, True)]) + # Created with it (CreateDirectoryW's security attributes), never set afterwards + self.assertEqual(self.acl.created, [self.root, *(os.path.join(self.root, name) + for name in ("state", "blocks", "storage", "names"))]) + self.assertEqual(self.acl.set_calls, []) self.assertTrue(self.private(self.root)) self.assertEqual(self.messages(), "") diff --git a/tests/test_identity.py b/tests/test_identity.py index 0c03432..1f6c75f 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -63,10 +63,11 @@ def test_loose_permissions_restricted_on_load(self): def test_no_permission_fix_on_windows(self): # Windows' st_mode always shows group and others allowed: no chmod or warning at every start load_or_create_identity(self.path) - with mock.patch.object(fsutil, "IS_WINDOWS", True), mock.patch("os.chmod") as chmod, \ - mock.patch.object(identity_module, "log") as log, \ - mock.patch("os.stat", return_value=mock.Mock(st_mode=0o100666)): - identity_module._restrict_permissions(self.path) + os.chmod(self.path, 0o666) + with mock.patch.object(fsutil, "IS_WINDOWS", True), mock.patch("os.fchmod") as fchmod, \ + mock.patch("os.chmod") as chmod, mock.patch.object(identity_module, "log") as log: + load_or_create_identity(self.path) + fchmod.assert_not_called() chmod.assert_not_called() log.assert_not_called() From 4383bfacf9db4bfd55be6ba8c63917920ac32b4f Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:57:08 +1030 Subject: [PATCH 096/107] Create the Windows rnfs dir and its subdirectories with the private access list, refuse a junction or symlink root or a new one that already holds files, and check rnfs.toml's owner and access list before use --- rnfs/config.py | 6 + rnfs/content/builder.py | 10 +- rnfs/fsutil.py | 13 ++ rnfs/node.py | 35 ++--- rnfs/winacl.py | 131 +++++++++++++++--- .../test_l7r1_windows_dir_creation.py | 123 ++++++++++++++++ tests/security/test_l7r2_junction_root.py | 81 +++++++++++ 7 files changed, 357 insertions(+), 42 deletions(-) create mode 100644 tests/security/test_l7r1_windows_dir_creation.py create mode 100644 tests/security/test_l7r2_junction_root.py diff --git a/rnfs/config.py b/rnfs/config.py index 96bf39d..59b6b49 100644 --- a/rnfs/config.py +++ b/rnfs/config.py @@ -10,6 +10,7 @@ import tomllib from dataclasses import dataclass, field, fields +from rnfs import winacl from rnfs.constants import DAY, GIB, HOUR, KIB, PROVIDER_TTL DEFAULT_API_PORT = 8788 @@ -264,6 +265,11 @@ def load(cls, rnfs_dir: str = "~/.rnfs/", **overrides) -> "Config": path = os.path.join(os.path.expanduser(rnfs_dir), SETTINGS_FILE) if os.path.isfile(path): with open(path, "rb") as f: + # On Windows, owned by and private to this user: its settings pick programs rnfs runs + try: + winacl.secure_settings(path, f.fileno()) + except OSError as e: + raise PermissionError(f"can't trust the settings file: {e}") from None values.update(tomllib.load(f)) known = {f.name for f in fields(cls)} unknown = values.keys() - known diff --git a/rnfs/content/builder.py b/rnfs/content/builder.py index 2a347af..24b1991 100644 --- a/rnfs/content/builder.py +++ b/rnfs/content/builder.py @@ -220,10 +220,6 @@ def is_executable(mode: int) -> bool: _O_NONBLOCK = getattr(os, "O_NONBLOCK", 0) # opening a FIFO for reading would otherwise wait for a writer _O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) _O_CLOEXEC = getattr(os, "O_CLOEXEC", 0) -_REPARSE_JUNCTION = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003) -# Reparse tags with this bit are name surrogates: they stand for another file or directory (symlinks, -# junctions, WSL symlinks), unlike placeholders such as OneDrive's that hold the file itself -_REPARSE_NAME_SURROGATE = 0x20000000 # Platforms without these (not Linux or macOS) walk directories by path, checking each entry before using it FD_WALK = (os.open in os.supports_dir_fd and os.scandir in os.supports_fd and hasattr(os, "O_NOFOLLOW") and hasattr(os, "O_DIRECTORY")) @@ -391,11 +387,7 @@ def listed_stat(item: os.DirEntry, path: str) -> os.stat_result: return st -def is_link(st: os.stat_result) -> bool: - """True for a symlink, a Windows directory junction (which stat reports as a directory, yet it links) or any - other Windows reparse point that stands for another file (a name surrogate).""" - tag = getattr(st, "st_reparse_tag", 0) - return stat.S_ISLNK(st.st_mode) or tag == _REPARSE_JUNCTION or bool(tag & _REPARSE_NAME_SURROGATE) +is_link = fsutil.is_link # a symlink, junction or other link-like Windows reparse point def close(handle: Handle): diff --git a/rnfs/fsutil.py b/rnfs/fsutil.py index 67ca2e7..e6f8af4 100644 --- a/rnfs/fsutil.py +++ b/rnfs/fsutil.py @@ -7,6 +7,7 @@ import errno import os import secrets +import stat import sys import time @@ -25,6 +26,11 @@ # Seconds slept between attempts while Windows reports a file in use: about 1 s in all RETRY_DELAYS = (0.01, 0.02, 0.05, 0.1, 0.15, 0.2, 0.2, 0.25) +_REPARSE_JUNCTION = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003) +# Reparse tags with this bit are name surrogates: they stand for another file or directory (symlinks, +# junctions, WSL symlinks), unlike placeholders such as OneDrive's that hold the file itself +_REPARSE_NAME_SURROGATE = 0x20000000 + # Device names Windows reserves in every directory, with or without an extension (also as superscript digits) _RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", *(f"{port}{digit}" for port in ("COM", "LPT") for digit in "123456789¹²³")} @@ -177,6 +183,13 @@ def _windows_final_path(fd: int) -> str: size = length + 1 # the buffer was too small, and length is what it needs +def is_link(st: os.stat_result) -> bool: + """True for a symlink, a Windows directory junction (which stat reports as a directory, yet it links) or any + other Windows reparse point that stands for another file (a name surrogate); st is from lstat.""" + tag = getattr(st, "st_reparse_tag", 0) + return stat.S_ISLNK(st.st_mode) or tag == _REPARSE_JUNCTION or bool(tag & _REPARSE_NAME_SURROGATE) + + def is_below(path: str, root: str) -> bool: """True if path is root or inside it; both should be real (resolved) paths. Case-insensitive on Windows.""" path, root = os.path.normcase(path), os.path.normcase(root) diff --git a/rnfs/node.py b/rnfs/node.py index 2450421..a760bd0 100644 --- a/rnfs/node.py +++ b/rnfs/node.py @@ -67,30 +67,31 @@ def _make_private_dirs(config: Config): """Create the rnfs dir and the node's subdirectories readable by the owner only (0700; on Windows, a - protected access list for the user and SYSTEM, see winacl). + protected access list for the user and SYSTEM from the moment each is created, see winacl). They hold the node's identity, name keys and the local API token, so other local users must not read them; a root left group- or world-accessible by earlier versions is tightened. Raises RnfsError if the root belongs to another user or can't be made private: its owner could swap the identity or plant a - config there. + config there. On Windows also if the root is a junction or symlink, or new but not empty. """ root = config.root - existed = os.path.isdir(root) + subdirs = (config.state_dir, config.blocks_dir, os.path.dirname(config.identity_path), config.names_dir) + if fsutil.IS_WINDOWS: + # No Unix permission bits there (st_mode always shows others allowed): an explicit access list + winacl.secure_rnfs_dir(root, subdirs) + return os.makedirs(root, mode=0o700, exist_ok=True) - # Windows has no Unix permission bits (st_mode always shows others allowed): an explicit access list there - winacl.secure_rnfs_dir(root, created=not existed) - if not fsutil.IS_WINDOWS: - info = os.stat(root) - if info.st_uid != os.geteuid(): - raise RnfsError(f"{root} belongs to another user (uid {info.st_uid}); the rnfs dir must be your own") - mode = stat.S_IMODE(info.st_mode) - if mode & 0o077: - try: - os.chmod(root, 0o700) - except OSError as e: - raise RnfsError(f"couldn't make {root} private to its owner (mode {mode:o}): {e}") from None - log(f"made {root} private to its owner (it had mode {mode:o})", LOG_NOTICE) - for directory in (config.state_dir, config.blocks_dir, os.path.dirname(config.identity_path), config.names_dir): + info = os.stat(root) + if info.st_uid != os.geteuid(): + raise RnfsError(f"{root} belongs to another user (uid {info.st_uid}); the rnfs dir must be your own") + mode = stat.S_IMODE(info.st_mode) + if mode & 0o077: + try: + os.chmod(root, 0o700) + except OSError as e: + raise RnfsError(f"couldn't make {root} private to its owner (mode {mode:o}): {e}") from None + log(f"made {root} private to its owner (it had mode {mode:o})", LOG_NOTICE) + for directory in subdirs: os.makedirs(directory, mode=0o700, exist_ok=True) diff --git a/rnfs/winacl.py b/rnfs/winacl.py index d5ba45b..e189192 100644 --- a/rnfs/winacl.py +++ b/rnfs/winacl.py @@ -6,11 +6,16 @@ Administrators and SYSTEM, but elsewhere (another drive, C:\\ProgramData) it usually lets every local user read the API token, the node's identity and the name keys. So on Windows: -- the daemon gives a new rnfs dir a protected DACL (nothing inherited from its parent) that grants full control - to the current user and SYSTEM only, inherited by everything created below it, and gives an existing one - that lets anyone else in the same DACL (which Windows passes on to what is below it); +- the daemon creates a new rnfs dir and its subdirectories with a protected DACL (nothing inherited from the + parent) that grants full control to the current user and SYSTEM only, inherited by everything created below + it. The DACL is part of the CreateDirectoryW call, so there is no moment when the parent's DACL applies and + another account could put a file in it; a dir that is new yet not empty is refused all the same. An existing + rnfs dir that lets anyone else in gets the same DACL (which Windows passes on to what is below it), and one + that is a junction or symlink is refused; - the API token, identity and name key files get that DACL explicitly when written (fsutil.write_atomic's - private), and an existing identity file that lets others in is tightened as on POSIX; + private); an existing identity or settings file (rnfs.toml) is checked through the handle it is read from + before use: one owned by another account is refused, one that lets others in is tightened, and a DACL that + can't be read or tightened stops rnfs; - every CLI command checks the token file's DACL before trusting it, and the daemon the rnfs dir's at start. A DACL counts as private if it allows nobody but the current user, SYSTEM and Administrators (who can read any @@ -45,6 +50,11 @@ INHERIT_ONLY = 0x8 FILE_ALL_ACCESS = 0x001F01FF ACL_REVISION = 2 +# Security descriptor control bits (winnt.h) +SE_DACL_PRESENT = 0x0004 +SE_DACL_PROTECTED = 0x1000 +SE_SELF_RELATIVE = 0x8000 +_DESCRIPTOR_HEADER = 20 # SECURITY_DESCRIPTOR_RELATIVE: revision, padding, control, four offsets class Ace(NamedTuple): @@ -123,6 +133,14 @@ def private_acl(user_sid: str, directory: bool) -> bytes: return struct.pack(" bytes: + """A self-relative security descriptor whose DACL is private_acl, marked protected (nothing inherited from + the parent), with no owner or group (the creating process's defaults apply), for CreateDirectoryW.""" + acl = private_acl(user_sid, directory) + control = SE_SELF_RELATIVE | SE_DACL_PRESENT | SE_DACL_PROTECTED + return struct.pack(" str: """A SID for messages, with the name of a well-known group.""" return f"{_NAMES[sid]} ({sid})" if sid in _NAMES else sid @@ -171,6 +189,7 @@ class _Windows: PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000 TOKEN_QUERY = 0x8 TOKEN_USER = 1 + ERROR_ALREADY_EXISTS = 183 def __init__(self): import ctypes @@ -202,6 +221,15 @@ def __init__(self): self.local_free = kernel32.LocalFree self.local_free.argtypes = (p,) self.local_free.restype = p + + class SecurityAttributes(ctypes.Structure): + """SECURITY_ATTRIBUTES (minwinbase.h).""" + _fields_ = (("nLength", wintypes.DWORD), ("lpSecurityDescriptor", p), ("bInheritHandle", wintypes.BOOL)) + + self.SecurityAttributes = SecurityAttributes + self.create_directory = kernel32.CreateDirectoryW + self.create_directory.argtypes = (wintypes.LPCWSTR, ctypes.POINTER(SecurityAttributes)) + self.create_directory.restype = wintypes.BOOL self._user_sid: str | None = None def _error(self, code: int, path: str | None = None) -> OSError: @@ -266,6 +294,20 @@ def set_private(self, path: str, directory: bool): if code != 0: raise self._error(code, path) + def create_private_dir(self, path: str) -> bool: + """Create the directory path with the protected DACL of private_acl from the start (CreateDirectoryW + with it in its SECURITY_ATTRIBUTES); False if something already exists at path. Raises OSError.""" + ctypes = self.ctypes + descriptor = ctypes.create_string_buffer(private_descriptor(self.user_sid(), True)) + attributes = self.SecurityAttributes(ctypes.sizeof(self.SecurityAttributes), + ctypes.cast(descriptor, ctypes.c_void_p), False) + if self.create_directory(path, ctypes.byref(attributes)): + return True + code = ctypes.get_last_error() + if code == self.ERROR_ALREADY_EXISTS: + return False + raise self._error(code, path) + backend = None # the _Windows backend, made at first use; tests replace it with a fake @@ -288,6 +330,20 @@ def make_private(path: str, directory: bool = False): acl.set_private(path, directory) +def make_private_dir(path: str) -> bool: + """Create the directory path (its parent must exist), on Windows with a protected DACL for the current user + and SYSTEM only from the start, elsewhere with mode 0700; False if something already exists at path. + Raises OSError.""" + acl = _backend() if fsutil.IS_WINDOWS else None + if acl is not None: + return acl.create_private_dir(path) + try: + os.mkdir(path, 0o700) + except FileExistsError: + return False + return True + + def check_private(path: str, fd: int | None = None) -> str | None: """On Windows, why the file open as fd (or at path) isn't private to the current user, or None if it is (and always None elsewhere). Raises OSError if its DACL can't be read.""" @@ -297,17 +353,18 @@ def check_private(path: str, fd: int | None = None) -> str | None: return problem(acl.read(path, fd), acl.user_sid()) -def tighten(path: str, directory: bool = False) -> str | None: +def tighten(path: str, directory: bool = False, fd: int | None = None) -> str | None: """On Windows, make an existing path private if its DACL lets others in; what was wrong (None if nothing). - Raises PermissionError if it belongs to someone else (its owner could open it again), OSError if it can't - be read or set. + With fd (path opened for reading), the owner and DACL checked, before and after, are the open file's, so a + file swapped in at path after the open can't pass for it. Raises PermissionError if it belongs to someone + else (its owner could open it again) or is still not private, OSError if its DACL can't be read or set. """ acl = _backend() if fsutil.IS_WINDOWS else None if acl is None: return None user = acl.user_sid() - dacl = acl.read(path) + dacl = acl.read(path, fd) reason = owner_problem(dacl, user) if reason is not None: raise PermissionError(f"{path}: {reason}") @@ -315,12 +372,24 @@ def tighten(path: str, directory: bool = False) -> str | None: if reason is None: return None acl.set_private(path, directory) - after = problem(acl.read(path), user) + after = problem(acl.read(path, fd), user) if after is not None: raise PermissionError(f"{path} is still not private after setting its access list: {after}") return reason +def secure_settings(path: str, fd: int): + """On Windows, check the settings file (rnfs.toml) open as fd before its settings are used: one that another + account owns is refused, one that lets others in is made private (with a warning, as they could have + changed it). Its settings choose the Reticulum config, and with it programs rnfs starts, and the API port + the CLI sends its token to. Raises PermissionError or OSError if it can't be trusted.""" + reason = tighten(path, fd=fd) + if reason is not None: + from rnfs.log import LOG_WARNING, log + log(f"the settings file {path} was accessible to other users ({reason}); made it private to you and " + f"SYSTEM. Check that its settings are yours", LOG_WARNING) + + def outside_profile(path: str) -> bool: """On Windows, True if path is outside the user's profile (%USERPROFILE%), whose ACL keeps other users out.""" if not fsutil.IS_WINDOWS: @@ -331,29 +400,59 @@ def outside_profile(path: str) -> bool: return not fsutil.is_below(os.path.realpath(path), os.path.realpath(profile)) -def secure_rnfs_dir(root: str, created: bool): - """On Windows, the rnfs dir's part of node._make_private_dirs: a protected owner-only DACL on a new root, - an existing one tightened if others may access it, and a warning for a root outside the user's profile. +def secure_rnfs_dir(root: str, subdirs: tuple[str, ...] = ()): + """On Windows, node._make_private_dirs: create the rnfs dir root and subdirs (below it) private from the + start, or check and tighten an existing root, and warn about a root outside the user's profile. - Raises RnfsError if root belongs to another account or can't be made private. + A new root is created with the protected owner-only DACL in the same call (make_private_dir), and must + then be empty: anything in it was put there by someone else. An existing one is made private if others + may access it. A root that is a junction, symlink or other link-like reparse point is refused, as the + access list checked and set would be its target's. Missing subdirectories get the same DACL when created. + + Raises RnfsError if root is a link, belongs to another account, can't be made private, or was found + non-empty when new. """ if not fsutil.IS_WINDOWS: return from rnfs.errors import RnfsError from rnfs.log import LOG_NOTICE, LOG_WARNING, log + path = os.path.abspath(root) try: + os.makedirs(os.path.dirname(path), exist_ok=True) + created = make_private_dir(path) + if fsutil.is_link(os.lstat(path)): + raise RnfsError(f"the rnfs dir {root} is a junction or symlink; rnfs uses only a real directory there " + f"(give its target as --dir)") if created: - make_private(root, directory=True) - reason = check_private(root) + entries = os.listdir(path) + if entries: + raise RnfsError(f"the rnfs dir {root} was just created, yet it already holds {len(entries)} " + f"entries (e.g. {entries[0]!r}) that someone else put there; delete it and " + f"keep it in a folder only you can change") + reason = check_private(path) if reason is not None: raise PermissionError(f"{root} is still not private after setting its access list: {reason}") else: - reason = tighten(root, directory=True) + reason = tighten(path, directory=True) if reason is not None: log(f"made {root} private to you and SYSTEM ({reason})", LOG_NOTICE) + for directory in subdirs: + _make_private_below(path, os.path.abspath(directory)) except OSError as e: raise RnfsError(f"couldn't make the rnfs dir {root} private to you: {e}") from None if outside_profile(root): log(f"the rnfs dir {root} is outside your user profile. rnfs has made it private to you, but accounts " f"that can change its parent folder could replace it: keep it in a folder only you can change", LOG_WARNING) + + +def _make_private_below(root: str, directory: str): + """Create directory (below root) and any missing directories between root and it with make_private_dir. + Raises OSError (FileExistsError if something other than a directory is in the way).""" + path = root + for part in os.path.relpath(directory, root).split(os.sep): + if part == ".": + continue + path = os.path.join(path, part) + if not make_private_dir(path) and not os.path.isdir(path): + raise FileExistsError(f"{path} is in the way of a directory") diff --git a/tests/security/test_l7r1_windows_dir_creation.py b/tests/security/test_l7r1_windows_dir_creation.py new file mode 100644 index 0000000..abe0991 --- /dev/null +++ b/tests/security/test_l7r1_windows_dir_creation.py @@ -0,0 +1,123 @@ +"""L7-R1 (fix): on Windows a new --dir outside the user's profile was created with its parent's access list and only +then given the private one, so in between another local account could put a file in it (an rnfs.toml whose +reticulum_dir points at a Reticulum config with a PipeInterface, which runs a program, or an identity), and the +settings and identity files were used without checking who owns them. Now the rnfs dir and its subdirectories are +created with the private DACL in the CreateDirectoryW call itself, a new root that already holds anything is +refused, rnfs.toml and the identity are checked (owner and DACL, through the handle they are read from) before +use, and a DACL that can't be tightened stops rnfs instead of being a warning. + +Simulated with fakes on any platform, as test_l7_windows_acl does: fsutil.IS_WINDOWS is patched and winacl.backend +is its FakeAcl (a DACL per file; create_private_dir makes the directory with the private DACL). The race is +stood in for by an os.mkdir that, right after making the root, writes an rnfs.toml into it, as another account +would. The security descriptor given to CreateDirectoryW is built in Python and checked byte for byte here; the +real CreateDirectoryW call through ctypes is not run: none of this has been run on Windows. +""" +import os +import struct +import unittest +from unittest import mock + +from rnfs import node as node_module, winacl +from rnfs.config import SETTINGS_FILE, Config +from rnfs.errors import RnfsError +from rnfs.winacl import Dacl +from tests.security.test_l7_windows_acl import OTHER, USER, WindowsTestCase, allow + +PLANTED = 'reticulum_dir = "C:/Users/Public/evil-reticulum"\n' + + +class NewRnfsDirTest(WindowsTestCase): + """node._make_private_dirs for a --dir that doesn't exist yet.""" + + def setUp(self): + super().setUp() + self.root = os.path.join(self.tmp.name, "rnfs") + for patch in (mock.patch("rnfs.log.log"), mock.patch.dict(os.environ, {"USERPROFILE": self.tmp.name})): + patch.start() + self.addCleanup(patch.stop) + + def test_root_and_subdirectories_are_created_private(self): + """Each directory has the private DACL from the call that creates it: none is ever set afterwards + (the old code made the root with os.makedirs, then set its DACL).""" + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + expected = [self.root] + [os.path.join(self.root, name) for name in ("state", "blocks", "storage", "names")] + self.assertEqual(self.acl.set_calls, [], "a DACL was set after the directory was created with its " + "parent's access list") + self.assertEqual(self.acl.created, expected) + for directory in expected: + self.assertTrue(self.private(directory), directory) + + def test_new_root_that_already_holds_a_file_is_refused(self): + """Someone who wins the race (here: an os.mkdir that plants rnfs.toml once the root exists) gets the + start refused, not their settings used.""" + real_mkdir = os.mkdir + + def mkdir_then_plant(path, *args, **kwargs): + real_mkdir(path, *args, **kwargs) + if os.path.abspath(path) == os.path.abspath(self.root): + with open(os.path.join(path, SETTINGS_FILE), "w") as f: + f.write(PLANTED) + + with mock.patch("os.mkdir", mkdir_then_plant): + with self.assertRaisesRegex(RnfsError, "was just created, yet it already holds 1 entries"): + node_module._make_private_dirs(Config(rnfs_dir=self.root)) + self.assertEqual(sorted(os.listdir(self.root)), [SETTINGS_FILE], "rnfs went on to use the planted dir") + + def test_trailing_separator(self): + """The default dir is given as "~/.rnfs/": its parent is the profile, not the dir itself.""" + node_module._make_private_dirs(Config(rnfs_dir=self.root + os.sep)) + self.assertEqual(self.acl.created[0], self.root) + + +class DescriptorTest(unittest.TestCase): + """The self-relative security descriptor CreateDirectoryW gets.""" + + def test_private_descriptor(self): + descriptor = winacl.private_descriptor(USER, True) + revision, _, control, owner, group, sacl, dacl = struct.unpack_from(" Date: Thu, 15 Oct 2026 14:57:08 +1030 Subject: [PATCH 097/107] Stop the node when its identity file is a symlink, another user's or not a regular file, or can't be made private, and read the key from the descriptor that was checked --- rnfs/network/identity.py | 132 ++++++++++++------ tests/security/test_i2r1_identity_file.py | 120 ++++++++++++++++ .../test_l7r1_windows_dir_creation.py | 33 +++++ 3 files changed, 245 insertions(+), 40 deletions(-) create mode 100644 tests/security/test_i2r1_identity_file.py diff --git a/rnfs/network/identity.py b/rnfs/network/identity.py index 96ed03e..3b8905c 100644 --- a/rnfs/network/identity.py +++ b/rnfs/network/identity.py @@ -6,28 +6,37 @@ from rnfs import fsutil, winacl from rnfs.constants import APP_NAME, DHT_ASPECT, ID_LENGTH +from rnfs.errors import RnfsError from rnfs.log import LOG_WARNING, log IDENTITY_MODE = 0o600 # the file holds the private key: readable by its owner only +MAX_IDENTITY_FILE = 4096 # RNS's key file is 64 bytes; a larger file isn't one + + +class IdentityFileError(RnfsError, RuntimeError): + """The identity file can't be used: a link, not a regular file, someone else's, or not made private. + Stops the node with this message (it is an RnfsError) rather than a traceback.""" def load_or_create_identity(path: str) -> RNS.Identity: - """The identity saved at path, or a new one saved there. Raises RuntimeError if it can't be read or written.""" - if not os.path.isfile(path): - os.makedirs(os.path.dirname(path), exist_ok=True) - identity = RNS.Identity() - try: - if save_identity(identity, path, replace=False): - return identity - except OSError as e: - raise RuntimeError(f"Could not save identity to {path}: {e}") from e - # Another process starting on the same directory saved its identity first: use that one - _restrict_permissions(path) - identity = RNS.Identity.from_file(path) - if identity is None: - # Don't overwrite: a new identity would mean a new node ID - raise RuntimeError(f"Could not load identity from {path}") - return identity + """The identity saved at path, or a new one saved there. Raises RuntimeError if it can't be read or written + (IdentityFileError if the file there can't be trusted).""" + try: + return _load_identity(path) + except FileNotFoundError: + pass + os.makedirs(os.path.dirname(path), exist_ok=True) + identity = RNS.Identity() + try: + if save_identity(identity, path, replace=False): + return identity + except OSError as e: + raise RuntimeError(f"Could not save identity to {path}: {e}") from e + # Another process starting on the same directory saved its identity first: use that one + try: + return _load_identity(path) + except FileNotFoundError: + raise RuntimeError(f"Could not load identity from {path}: it disappeared") from None def save_identity(identity: RNS.Identity, path: str, replace: bool = True) -> bool: @@ -52,40 +61,83 @@ def save_identity(identity: RNS.Identity, path: str, replace: bool = True) -> bo return True -def _restrict_permissions(path: str): - """Make an existing identity file owner-only if group or others could access it (e.g. an older rnfs's). +def _load_identity(path: str) -> RNS.Identity: + """The identity in the existing file at path, read through the very descriptor whose type, owner and + permissions were checked. Raises FileNotFoundError if nothing is at path, IdentityFileError if what is + there can't be trusted, RuntimeError if it holds no key. - Never through a symlink: a symlink at path is left alone, with a warning, and the file is opened without - following one (O_NOFOLLOW) and changed through that descriptor, so a link put there can't get another - file's permissions changed. On Windows, which has no Unix permission bits (st_mode always shows group and - others allowed), its access list is checked and tightened instead (winacl). + The file must be a regular file (a FIFO would block the start, a device be read without end), not a + symlink or junction (someone who can write to the directory could point it at a key of theirs), owned by + this user, and private to them: an existing file others may read (e.g. an older rnfs's) is tightened + through the descriptor (with a warning), and if that fails rnfs stops, since the key may be read by others. + On Windows, which has no Unix permission bits (st_mode always shows group and others allowed), its owner + and access list are checked and tightened instead (winacl). """ + try: + listed = os.lstat(path) + except FileNotFoundError: + raise + except OSError as e: + raise IdentityFileError(f"can't use the identity file {path}: {e}") from None + if fsutil.is_link(listed): + raise IdentityFileError(f"the identity file {path} is a symlink or junction; rnfs only uses a regular " + f"file there. Put the key file itself there (it decides the node ID)") + try: + fd = os.open(path, os.O_RDONLY | fsutil.O_NOFOLLOW | fsutil.O_BINARY | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0)) + except FileNotFoundError: + raise + except OSError as e: # ELOOP: swapped for a symlink since the lstat + raise IdentityFileError(f"can't use the identity file {path}: {e}") from None + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode): + raise IdentityFileError(f"the identity file {path} is not a regular file; rnfs only uses a regular " + f"file there") + if (info.st_dev, info.st_ino) != (listed.st_dev, listed.st_ino): + raise IdentityFileError(f"the identity file {path} changed while being opened") + _check_private(path, fd, info) + data = b"" + while len(data) <= MAX_IDENTITY_FILE: + chunk = os.read(fd, MAX_IDENTITY_FILE + 1 - len(data)) + if not chunk: + break + data += chunk + except OSError as e: + raise IdentityFileError(f"can't use the identity file {path}: {e}") from None + finally: + os.close(fd) + identity = RNS.Identity.from_bytes(data) if len(data) <= MAX_IDENTITY_FILE else None + if identity is None: + # Don't overwrite: a new identity would mean a new node ID + raise RuntimeError(f"Could not load identity from {path}") + return identity + + +def _check_private(path: str, fd: int, info: os.stat_result): + """Check that the identity file open as fd (whose fstat is info) is the user's own and private to them, + tightening its permissions if others may access it. Raises IdentityFileError, OSError.""" if fsutil.IS_WINDOWS: try: - reason = winacl.tighten(path) + reason = winacl.tighten(path, fd=fd) except OSError as e: - log(f"could not restrict access to identity file {path}: {e}", LOG_WARNING) - return + raise IdentityFileError(f"the identity file {path} can't be made private to you: {e}") from None if reason is not None: log(f"identity file {path} was accessible to other users ({reason}); " f"restricted it to you and SYSTEM", LOG_WARNING) return - try: - if stat.S_ISLNK(os.lstat(path).st_mode): - log(f"identity file {path} is a symlink, so its permissions weren't checked; make sure only you " - f"can read the file it points to", LOG_WARNING) - return - fd = os.open(path, os.O_RDONLY | fsutil.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0)) + if info.st_uid != os.geteuid(): + raise IdentityFileError(f"the identity file {path} belongs to another user (uid {info.st_uid}); " + f"rnfs only uses a key file of your own") + mode = stat.S_IMODE(info.st_mode) + if mode & 0o077: try: - mode = stat.S_IMODE(os.fstat(fd).st_mode) - if mode & 0o077: - os.fchmod(fd, IDENTITY_MODE) - log(f"identity file {path} was accessible to other users (mode {mode:o}); " - f"restricted it to {IDENTITY_MODE:o}", LOG_WARNING) - finally: - os.close(fd) - except OSError as e: - log(f"could not restrict the permissions of identity file {path}: {e}", LOG_WARNING) + os.fchmod(fd, IDENTITY_MODE) + except OSError as e: + raise IdentityFileError(f"the identity file {path} is accessible to other users (mode {mode:o}) " + f"and can't be restricted: {e}") from None + log(f"identity file {path} was accessible to other users (mode {mode:o}); " + f"restricted it to {IDENTITY_MODE:o}", LOG_WARNING) def node_id_for_identity(identity: RNS.Identity) -> bytes: diff --git a/tests/security/test_i2r1_identity_file.py b/tests/security/test_i2r1_identity_file.py new file mode 100644 index 0000000..1ee9084 --- /dev/null +++ b/tests/security/test_i2r1_identity_file.py @@ -0,0 +1,120 @@ +"""I2-R1 (fix): an identity file that was a symlink, belonged to another user or wasn't a regular file was still +used: a symlink (or one belonging to someone else) was loaded with at most a warning, so whoever could put it there +chose the node's key, and a FIFO made the start hang, as RNS read it by path with a blocking open. The key was +also read by path again after the permission check, so a file swapped in between was the one loaded. Now each +of these stops the node with a clear error (IdentityFileError, an RnfsError, so the daemon reports it without a +traceback), and the key is read from the O_NOFOLLOW descriptor that was checked. + +Reproduced with real files, symlinks and a FIFO in a temporary directory (POSIX); "another user" is an +os.geteuid that answers a different uid, and the swap happens in the os.fchmod that tightens a loose file. +""" +import os +import tempfile +import threading +import unittest +from unittest import mock + +import RNS + +from rnfs.errors import RnfsError +from rnfs.network import identity as identity_module +from rnfs.network.identity import load_or_create_identity +from tests.support import CAN_SYMLINK, HAS_FIFO, POSIX_MODES + + +@unittest.skipUnless(POSIX_MODES, "Unix permission bits and owners") +class IdentityFileTest(unittest.TestCase): + """storage/identity under a temporary directory, and a key file of someone else's next to it.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + os.mkdir(os.path.join(self.tmp.name, "storage")) + self.path = os.path.join(self.tmp.name, "storage", "identity") + self.theirs = RNS.Identity() + self.their_file = os.path.join(self.tmp.name, "their-key") + self.theirs.to_file(self.their_file) + log = mock.patch.object(identity_module, "log") + self.log = log.start() + self.addCleanup(log.stop) + + def load(self): + """load_or_create_identity, which must stop the node with an RnfsError (the daemon reports it as a + message, not a traceback); its message.""" + with self.assertRaises(RnfsError) as caught: + load_or_create_identity(self.path) + self.assertIsInstance(caught.exception, identity_module.IdentityFileError) + return str(caught.exception) + + @unittest.skipUnless(CAN_SYMLINK, "symlinks") + def test_symlink_stops_the_node(self): + os.symlink(self.their_file, self.path) + self.assertIn("is a symlink", self.load()) + + @unittest.skipUnless(CAN_SYMLINK, "symlinks") + def test_dangling_symlink_stops_the_node(self): + """A symlink to nothing is refused as a symlink too (it used to end in "Could not load identity").""" + os.symlink(os.path.join(self.tmp.name, "nothing"), self.path) + self.assertIn("is a symlink", self.load()) + self.assertFalse(os.path.exists(os.path.join(self.tmp.name, "nothing"))) + + def test_identity_of_another_user_stops_the_node(self): + os.replace(self.their_file, self.path) + os.chmod(self.path, 0o600) + with mock.patch("os.geteuid", return_value=os.geteuid() + 1): + self.assertIn("belongs to another user", self.load()) + + @unittest.skipUnless(HAS_FIFO, "FIFOs") + def test_fifo_stops_the_node_without_hanging(self): + os.mkfifo(self.path, 0o600) + outcome = [] + + def start(): + try: + load_or_create_identity(self.path) + outcome.append("loaded") + except Exception as e: # noqa: BLE001 - whatever it is, the test reports it + outcome.append(e) + + thread = threading.Thread(target=start, daemon=True) + thread.start() + thread.join(5) + if thread.is_alive(): + # Unblock the old code's blocking open: a writer that closes at once gives it an empty read + os.close(os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)) + thread.join(5) + self.fail("the start hung opening the FIFO at the identity path") + [result] = outcome + self.assertIsInstance(result, RnfsError) + self.assertIn("not a regular file", str(result)) + + def test_key_is_read_from_the_checked_descriptor(self): + """A file swapped in at the path right after the check (here: as the loose file is tightened) is not + the one loaded.""" + mine = RNS.Identity() + mine.to_file(self.path) + os.chmod(self.path, 0o644) # loose, so the check tightens it + real_fchmod = os.fchmod + + def fchmod_then_swap(fd, mode): + real_fchmod(fd, mode) + os.replace(self.their_file, self.path) + + with mock.patch("os.fchmod", fchmod_then_swap): + loaded = load_or_create_identity(self.path) + self.assertEqual(loaded.hash, mine.hash, "loaded the key swapped in after the check") + + def test_failed_tighten_stops_the_node(self): + RNS.Identity().to_file(self.path) + os.chmod(self.path, 0o644) + with mock.patch("os.fchmod", side_effect=PermissionError(1, "Operation not permitted")): + self.assertIn("can't be restricted", self.load()) + + def test_own_private_identity_still_loads(self): + created = load_or_create_identity(self.path) + self.assertEqual(load_or_create_identity(self.path).hash, created.hash) + self.log.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/security/test_l7r1_windows_dir_creation.py b/tests/security/test_l7r1_windows_dir_creation.py index abe0991..ebc2d50 100644 --- a/tests/security/test_l7r1_windows_dir_creation.py +++ b/tests/security/test_l7r1_windows_dir_creation.py @@ -17,9 +17,12 @@ import unittest from unittest import mock +import RNS + from rnfs import node as node_module, winacl from rnfs.config import SETTINGS_FILE, Config from rnfs.errors import RnfsError +from rnfs.network import identity as identity_module from rnfs.winacl import Dacl from tests.security.test_l7_windows_acl import OTHER, USER, WindowsTestCase, allow @@ -119,5 +122,35 @@ def test_private_settings_load_quietly(self): self.assertEqual(self.acl.set_calls, []) +class IdentityFileTest(WindowsTestCase): + """load_or_create_identity on Windows: a foreign or untightenable identity stops the node.""" + + def setUp(self): + super().setUp() + self.path = os.path.join(self.tmp.name, "storage", "identity") + os.makedirs(os.path.dirname(self.path)) + RNS.Identity().to_file(self.path) + + def test_identity_of_another_account_is_refused(self): + self.acl.set(self.path, Dacl(OTHER, [allow(OTHER), allow(USER)])) + with mock.patch.object(identity_module, "log"): + with self.assertRaisesRegex(RnfsError, f"belongs to {OTHER}"): + identity_module.load_or_create_identity(self.path) + + def test_identity_that_cant_be_tightened_stops_the_node(self): + with mock.patch.object(self.acl, "set_private", side_effect=PermissionError(5, "Access is denied")), \ + mock.patch.object(identity_module, "log"): + with self.assertRaisesRegex(RnfsError, "can't be made private to you"): + identity_module.load_or_create_identity(self.path) + + def test_identity_dacl_is_checked_through_the_opened_file(self): + with mock.patch.object(identity_module, "log"): + identity_module.load_or_create_identity(self.path) + reads = list(self.acl.read_fds) + self.assertTrue(reads and all(fd is not None for fd in reads), + "the identity's DACL was read by path, not from the file read") + self.assertTrue(self.private(self.path)) + + if __name__ == "__main__": unittest.main() From 1d8e0995ff6c31c4d329b81af05c64b1ea349459 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:57:13 +1030 Subject: [PATCH 098/107] Skip files with other hard links when scanning the shared folder --- rnfs/content/builder.py | 30 +++++-- rnfs/content/shared_folder.py | 15 +++- tests/security/test_l8r2_hard_links.py | 106 +++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 tests/security/test_l8r2_hard_links.py diff --git a/rnfs/content/builder.py b/rnfs/content/builder.py index 24b1991..192ccbd 100644 --- a/rnfs/content/builder.py +++ b/rnfs/content/builder.py @@ -410,21 +410,32 @@ def build_path(path: str, put: Put, warnings: list[str]) -> Built: close(handle) +def hard_links(handle: Handle) -> int: + """How many hard links the open regular file handle has beyond one (0 for a directory).""" + if not isinstance(handle, int): + return 0 + st = os.fstat(handle) + return st.st_nlink - 1 if stat.S_ISREG(st.st_mode) and st.st_nlink > 1 else 0 + + def build_open(handle: Handle, st: os.stat_result, path: str, put: Put, warnings: list[str], - depth: int = 1) -> Built: + depth: int = 1, skip_hard_links: bool = False) -> Built: """Build the regular file or directory open as handle (from open_path or open_child), whose stat is st. - path is only for messages (and for directories without FD_WALK); depth as for _build_tree. + path is only for messages (and for directories without FD_WALK); depth as for _build_tree. With + skip_hard_links, files below a directory that have other hard links are skipped with a warning (the shared + folder: someone who may write to it could link a file of yours there that you never put in it). """ if stat.S_ISDIR(st.st_mode): - return _build_tree(handle, path, put, warnings, depth) + return _build_tree(handle, path, put, warnings, depth, skip_hard_links) kind = dag.KIND_EXECUTABLE if is_executable(st.st_mode) else dag.KIND_FILE # Reading a regular file never blocks, so O_NONBLOCK can stay; the caller keeps the descriptor with os.fdopen(handle, "rb", closefd=False) as f: return build_file(f, put, kind=kind) -def _build_tree(directory: Handle, path: str, put: Put, warnings: list[str], depth: int) -> Built: +def _build_tree(directory: Handle, path: str, put: Put, warnings: list[str], depth: int, + skip_hard_links: bool = False) -> Built: """Build the open directory at path, which sits depth directories from the root (1 for the root).""" # Every directory level adds at least one DAG node, so this bounds the recursion before it gets deep if depth > constants.MAX_DEPTH: @@ -451,7 +462,10 @@ def _build_tree(directory: Handle, path: str, put: Put, warnings: list[str], dep warnings.append(f"skipped {shown}: it changed while being added") continue try: - child = build_open(handle, st, child_path, put, warnings, depth + 1) + if skip_hard_links and (links := hard_links(handle)): + warnings.append(f"skipped {shown}: {hard_link_reason(links)}") + continue + child = build_open(handle, st, child_path, put, warnings, depth + 1, skip_hard_links) except Changed: # A directory walked by path, swapped when it was listed (see list_directory) warnings.append(f"skipped {shown}: it changed while being added") @@ -463,6 +477,12 @@ def _build_tree(directory: Handle, path: str, put: Put, warnings: list[str], dep return build_directory(entries, put, child_height) +def hard_link_reason(links: int) -> str: + """Why a file with links other hard links is skipped (shared folder).""" + return (f"it has {links} other hard link{'s' if links > 1 else ''}, so it is also a file elsewhere that may " + f"not be meant for sharing") + + def add_path(path: str, put: Put, wrap_it: bool = False) -> tuple[Built, list[str]]: """Build the file or directory at path, optionally wrapped in a directory under its own name. diff --git a/rnfs/content/shared_folder.py b/rnfs/content/shared_folder.py index ac5f6e7..48e6e84 100644 --- a/rnfs/content/shared_folder.py +++ b/rnfs/content/shared_folder.py @@ -45,8 +45,11 @@ def open_signature(handle: builder.Handle, st: os.stat_result, path: str) -> str def add(item: builder.Handle | None, item_path: str, relative: str, st: os.stat_result, depth: int): """Fold one item (and, for a directory, open as item, everything in it) into the digest.""" mode = st.st_mode - digest.update(json.dumps([relative.encode("utf-8", "surrogateescape").hex(), stat.S_IFMT(mode), - st.st_size, st.st_mtime_ns, builder.is_executable(mode)]).encode()) + fields = [relative.encode("utf-8", "surrogateescape").hex(), stat.S_IFMT(mode), st.st_size, + st.st_mtime_ns, builder.is_executable(mode)] + if stat.S_ISREG(mode) and st.st_nlink > 1: + fields.append("linked") # skipped while linked elsewhere: imported once that link is gone + digest.update(json.dumps(fields).encode()) # Deeper than MAX_DEPTH can't be added anyway; don't recurse without bound if item is None or not stat.S_ISDIR(mode) or depth > constants.MAX_DEPTH + 1: return @@ -155,6 +158,12 @@ def _scan_item(self, folder: builder.Handle, item: os.DirEntry) -> tuple[str, Ci except OSError: return None # deleted, swapped or unreadable mid-scan; it'll be picked up next time if it comes back try: + # A file with another hard link is also a file elsewhere, which someone who may write here could + # have linked in (audit L8-R2): skipped, so its pin goes too + links = builder.hard_links(handle) + if links: + self._warn_once(f"hardlink:{item.name}", f"skipped {shown}: {builder.hard_link_reason(links)}") + return None return self._import(item.name, handle, st, path) finally: builder.close(handle) @@ -175,7 +184,7 @@ def _import(self, name: str, handle: builder.Handle, st: os.stat_result, path: s warnings: list[str] = [] try: with self.storage.protect() as session: - built = builder.build_open(handle, st, path, session.put, warnings) + built = builder.build_open(handle, st, path, session.put, warnings, skip_hard_links=True) self.storage.pin(built.cid, FOLDER) except (OSError, ValueError, RnfsError) as e: self._failed[name] = sig diff --git a/tests/security/test_l8r2_hard_links.py b/tests/security/test_l8r2_hard_links.py new file mode 100644 index 0000000..e6da5f6 --- /dev/null +++ b/tests/security/test_l8r2_hard_links.py @@ -0,0 +1,106 @@ +"""L8-R2 (fix): the shared folder imported regular files that have other hard links, so anyone who may write to +files/ could hard link a file of the owner's (a key, a document elsewhere on the same filesystem) into it and have +it published, even though links are otherwise skipped there. Now a folder scan skips a regular file whose link +count is above one, at the top level and inside shared directories, with a log line; once its other link is gone +it is imported at the next scan. `rnfs add` is unchanged (the user names what to add). + +Reproduced with real hard links (os.link) in a temporary shared folder; the link count is read from the opened +file, as on Windows (NTFS hard links), but this has only been run on Linux. +""" +import os +import tempfile +import unittest +from unittest import mock + +from rnfs.content import builder, dag, shared_folder +from rnfs.content.shared_folder import SharedFolder +from rnfs.content.storage import FOLDER, Storage + +SECRET = b"the owner's private key, kept outside the shared folder\n" + + +def can_hard_link(directory: str) -> bool: + """True if directory's filesystem allows hard links.""" + probe = os.path.join(directory, "probe") + with open(probe, "wb"): + pass + try: + os.link(probe, probe + "-link") + except OSError: + return False + finally: + for path in (probe, probe + "-link"): + if os.path.exists(path): + os.remove(path) + return True + + +class HardLinkTest(unittest.TestCase): + """A shared folder, and a secret file of the owner's outside it on the same filesystem.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + if not can_hard_link(self.tmp.name): + self.skipTest("no hard links on this filesystem") + self.files = os.path.join(self.tmp.name, "files") + os.mkdir(self.files) + self.secret = os.path.join(self.tmp.name, "secret.key") + with open(self.secret, "wb") as f: + f.write(SECRET) + self.storage = Storage(os.path.join(self.tmp.name, "blocks"), os.path.join(self.tmp.name, "state"), 10**9) + self.folder = SharedFolder(self.files, self.storage, os.path.join(self.tmp.name, "state")) + self.secret_cid = builder.add_path(self.secret, builder.NO_PUT)[0].cid + log = mock.patch.object(shared_folder, "log") + self.log = log.start() + self.addCleanup(log.stop) + + def messages(self) -> str: + return "\n".join(call.args[0] for call in self.log.call_args_list) + + def stored(self) -> bool: + """True if the secret's content made it into the block store.""" + return self.storage.has(self.secret_cid) + + def test_top_level_hard_link_is_skipped(self): + os.link(self.secret, os.path.join(self.files, "notes.txt")) + self.assertEqual(self.folder.scan(), set()) + self.assertNotIn(self.secret_cid, self.storage.pinned_by(FOLDER), "a hard-linked file was shared") + self.assertFalse(self.stored()) + self.assertIn("notes.txt: it has 1 other hard link", self.messages()) + + def test_hard_link_inside_a_shared_directory_is_skipped(self): + os.makedirs(os.path.join(self.files, "site")) + with open(os.path.join(self.files, "site", "index.mu"), "wb") as f: + f.write(b">Home\n") + os.link(self.secret, os.path.join(self.files, "site", "backup.txt")) + [pin] = self.folder.scan() + self.assertFalse(self.stored(), "a hard-linked file inside a shared directory was imported") + self.assertEqual([entry.name for entry in dag.decode(self.storage.get(pin)).entries], ["index.mu"]) + self.assertIn("backup.txt: it has 1 other hard link", self.messages()) + + def test_imported_once_the_other_link_is_gone(self): + """A file that only had a second link (say, a backup tool's) is shared once that link is removed.""" + os.link(self.secret, os.path.join(self.files, "notes.txt")) + self.folder.scan() + os.remove(self.secret) + self.assertEqual(self.folder.scan(), {self.secret_cid}) + + def test_existing_pin_of_a_file_linked_later_is_released(self): + shared = os.path.join(self.files, "notes.txt") + with open(shared, "wb") as f: + f.write(b"public notes\n") + [cid] = self.folder.scan() + os.link(shared, os.path.join(self.tmp.name, "elsewhere")) + self.assertEqual(self.folder.scan(), set()) + self.assertNotIn(cid, self.storage.pinned_by(FOLDER)) + + def test_add_of_a_hard_linked_file_is_unchanged(self): + """`rnfs add` builds what the user names, links or not.""" + os.link(self.secret, os.path.join(self.tmp.name, "linked")) + built, warnings = builder.add_path(os.path.join(self.tmp.name, "linked"), builder.NO_PUT) + self.assertEqual((built.cid, warnings), (self.secret_cid, [])) + + +if __name__ == "__main__": + unittest.main() From 3707b537ad94c8bfa350ca8e49d35d626a4528dc Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:57:13 +1030 Subject: [PATCH 099/107] Show only allowlisted CIDs on display-mu-file in private mode, and rate-limit each identified visitor on their own --- .../display-mu-file/index.mu | 79 ++++++++--- .../test_h2r_display_page_private_and_rate.py | 124 ++++++++++++++++++ 2 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 tests/security/test_h2r_display_page_private_and_rate.py diff --git a/companion-nomadnet-sites/display-mu-file/index.mu b/companion-nomadnet-sites/display-mu-file/index.mu index 2b1fd15..0d330ac 100755 --- a/companion-nomadnet-sites/display-mu-file/index.mu +++ b/companion-nomadnet-sites/display-mu-file/index.mu @@ -6,12 +6,15 @@ Link to it with the CID as a link variable, which NomadNet passes in the environ Anyone can open the page, so it never fetches, pins or provides anything because a visitor asked: - It shows a file only if this node already has it completely and pinned by the operator (pin source "user" - or "folder", as `rnfs pin ls` lists it), or if its CID is in the operator's allowlist file. Only - allowlisted CIDs are fetched if missing, with `rnfs cat` (a bounded read that pins nothing and starts no - job), and only allowlisted files are rendered as Micron; everything else is shown as plain text under a - banner saying it is third-party content. -- Requests run one at a time (a lock file) and at most RATE_LIMIT per RATE_WINDOW seconds (a state file); - when either says no, the page asks the visitor to try again later. + or "folder", as `rnfs pin ls` lists it), or if its CID is in the operator's allowlist file. In private + mode (`rnfs status`), only allowlisted CIDs: the node shares its pins with members only, and the page + can't tell who is one. Only allowlisted CIDs are fetched if missing, with `rnfs cat` (a bounded read that + pins nothing and starts no job), and only allowlisted files are rendered as Micron; everything else is + shown as plain text under a banner saying it is third-party content. +- Requests run one at a time (a lock file) and within rate limits (a state file): VISITOR_LIMIT per + RATE_WINDOW seconds for each visitor who identified to this node (NomadNet passes their identity as + remote_identity), RATE_LIMIT for all who didn't together, and TOTAL_LIMIT for everyone; when the lock or a + limit says no, the page asks the visitor to try again later. - Visitors see short, generic errors; the details (CLI errors, exceptions) go to a log file. The allowlist, lock, rate state and log live in the page's data dir, PAGE_DIR; see this page's README. @@ -33,8 +36,14 @@ MAX_SIZE = 200 * 1024 # How long one page request may wait for rnfs in all; fetching an allowlisted file over a slow link takes a while. TIMEOUT = 120 -# At most RATE_LIMIT page requests (for well-formed CIDs) in any RATE_WINDOW seconds, from all visitors together. +# Page requests (for well-formed CIDs) in any RATE_WINDOW seconds: at most VISITOR_LIMIT from each visitor who +# identified to this node, RATE_LIMIT from all who didn't together (nothing tells them apart: NomadNet's link_id +# is new with every link), and TOTAL_LIMIT from everyone. Identities cost nothing to make, so the per-visitor +# limit keeps one ordinary visitor from using up the others' requests, not a determined one: TOTAL_LIMIT still +# bounds what the page makes rnfs do. +VISITOR_LIMIT = 10 RATE_LIMIT = 30 +TOTAL_LIMIT = 60 RATE_WINDOW = 60 # Pin sources that mean the operator chose to keep the file (not a contact, a follow or a remote pin). @@ -54,6 +63,9 @@ RNFS = shutil.which("rnfs") or os.path.expanduser("~/.local/bin/rnfs") # A bare CIDv1 in base32 (rnfs's form), so nothing else (a path, a nick, an option) reaches rnfs. CID = re.compile(r"b[a-z2-7]{20,200}") +# An RNS identity hash in hex, as NomadNet passes a visitor's in remote_identity. +IDENTITY = re.compile(r"[0-9a-f]{32}") + # How a sealed (encrypted) file starts (rnfs/content/seal.py); the page never shows one. SEALED_MAGIC = b"rnfs-sealed\n" @@ -169,24 +181,40 @@ def take_lock(): return handle -def over_rate_limit(): - """True if RATE_LIMIT requests already ran in the last RATE_WINDOW seconds; else count this one. +def visitor(): + """The identity hash of the visitor, if they identified to this node (NomadNet's remote_identity), else + None. NomadNet's link_id isn't used: a visitor gets a new one with every link.""" + value = os.environ.get("remote_identity", "").strip().lower() + return value if IDENTITY.fullmatch(value) else None + + +def over_rate_limit(who): + """True if visitor who (None: one who didn't identify) may not make another request now: VISITOR_LIMIT + (RATE_LIMIT for all who didn't identify) or TOTAL_LIMIT requests ran in the last RATE_WINDOW seconds. + Else count this one. - Called with the lock held. Raises OSError if the state can't be saved, so the page fails closed. + Called with the lock held. The state is a list of the requests' times, each with the visitor's identity + hash ([time, hash]) if they identified. Raises OSError if it can't be saved, so the page fails closed. """ path = os.path.join(PAGE_DIR, "rate.json") now = time.time() + requests = [] try: with open(path, encoding="utf-8") as f: - stamps = [stamp for stamp in json.load(f) - if isinstance(stamp, (int, float)) and now - RATE_WINDOW < stamp <= now] + saved = json.load(f) + for entry in saved if isinstance(saved, list) else (): + stamp, owner = entry if isinstance(entry, list) and len(entry) == 2 else (entry, None) + if (isinstance(stamp, (int, float)) and now - RATE_WINDOW < stamp <= now + and (owner is None or isinstance(owner, str) and IDENTITY.fullmatch(owner))): + requests.append((stamp, owner)) except (OSError, ValueError, TypeError): - stamps = [] - if len(stamps) >= RATE_LIMIT: + requests = [] + mine = sum(owner == who for _, owner in requests) + if len(requests) >= TOTAL_LIMIT or mine >= (RATE_LIMIT if who is None else VISITOR_LIMIT): return True - stamps.append(now) + requests.append((now, who)) with open(path + ".tmp", "w", encoding="utf-8") as f: - json.dump(stamps, f) + json.dump([stamp if owner is None else [stamp, owner] for stamp, owner in requests], f) os.replace(path + ".tmp", path) return False @@ -215,6 +243,19 @@ def operator_pin(deadline, cid): return None +def private_mode(deadline, cid): + """True unless `rnfs status` says the node isn't in private mode (an answer without it counts as private).""" + code, out, err = rnfs(deadline, "status") + if code != 0: + unavailable(cid, f"rnfs status failed: {err}") + for line in out.decode("utf-8", "replace").splitlines(): + name, _, value = line.partition(": ") + if name == "private": + return value.strip() != "False" + log(f"{cid}: rnfs status didn't say whether the node is in private mode; taken as private") + return True + + def size_of(deadline, cid): """The file's size in bytes from rnfs stat, or None if it isn't known.""" code, out, err = rnfs(deadline, "stat", cid) @@ -236,6 +277,10 @@ def show(cid): # Not this node's to share: run nothing that could fetch it (stat and cat would, and with # provide_strategy "roots" the node would then provide what they fetched) fail(f"{cid} isn't shared on this node.") + if not allowed and private_mode(deadline, cid): + # The node shares its pins with members only, and the page can't tell who is one: only what the + # operator allowlisted for everyone (the same answer as for a CID it doesn't have) + fail(f"{cid} isn't shared on this node.") if pin is not None and pin.get("type") == "directory": fail(f"{cid} is a folder, not a file.") @@ -282,7 +327,7 @@ def main(): fail("This page is busy with another request; try again in a moment.") with lock: try: - if over_rate_limit(): + if over_rate_limit(visitor()): fail("This page has had too many requests; try again in a minute.") show(cid) except subprocess.TimeoutExpired: diff --git a/tests/security/test_h2r_display_page_private_and_rate.py b/tests/security/test_h2r_display_page_private_and_rate.py new file mode 100644 index 0000000..acd08ff --- /dev/null +++ b/tests/security/test_h2r_display_page_private_and_rate.py @@ -0,0 +1,124 @@ +"""H2-R (fix), two Low residuals of the display-mu-file page: + +- Private mode: the page showed any complete operator pin (source user or folder) to anyone who knew its CID, so + on a node in private mode, which serves its content to members only, any NomadNet visitor could read its + unsealed pinned files up to MAX_SIZE. Now, in private mode (as `rnfs status` says; an answer without it counts + as private), only allowlisted CIDs are shown. +- Rate limit: RATE_LIMIT counted all visitors together, so one visitor's requests locked everyone else out for + RATE_WINDOW. NomadNet passes a page the visitor's identity hash (remote_identity) when they identified on the + link, and a link_id that is new with every link. Now each identified visitor has VISITOR_LIMIT of their own, + those who didn't identify share RATE_LIMIT (nothing tells them apart), and TOTAL_LIMIT still bounds everyone. + Identities cost nothing to make, so this stops an ordinary visitor using up the others' requests, not a + determined one (who can still reach TOTAL_LIMIT with several identities). + +The page's real script runs against an offline victim node through the fake rnfs CLI of _pages_helpers, as in +test_h2_display_page_pins; NomadNet's environment is stood in for by remote_identity in os.environ. +""" +import io +import os +import unittest +from unittest import mock + +from tests.security import _pages_helpers as helpers +from tests.security.test_h2_display_page_pins import CONTENT, MICRON, PageTestCase + + +def identity(n: int) -> str: + """A visitor's identity hash as NomadNet passes it (32 hex digits).""" + return f"{n:032x}" + + +class PrivateModeTest(PageTestCase): + """The victim's own pins, in and out of private mode.""" + + def setUp(self): + super().setUp() + self.notes = self.victim.add_stream(io.BytesIO(b"members-only notes\n"))["cid"] + + def test_operator_pin_is_not_shown_in_private_mode(self): + self.victim.config.private = True + output, calls = self.view(self.notes) + self.assertNotIn("members-only notes", output, "a private node's pin was shown to a visitor") + self.assertIn("isn't shared on this node", output) + self.assertEqual([call[0] for call in calls], ["pin", "status"]) # nothing read + + def test_allowlisted_cid_is_shown_in_private_mode(self): + self.victim.config.private = True + page = self.victim.add_stream(io.BytesIO(MICRON))["cid"] + helpers.allow(self.data_dir, page) + output, calls = self.view(page) + self.assertEqual(output, MICRON.decode()) + self.assertNotIn(["status"], calls) # the allowlist is the operator's choice either way + + def test_operator_pin_is_shown_outside_private_mode(self): + """Unchanged (a guard): a public node's own pins are still shown, as plain text.""" + output, _ = self.view(self.notes) + self.assertIn("members-only notes", output) + + def test_status_without_private_counts_as_private(self): + """Fails closed if `rnfs status` doesn't say (an older or odd daemon).""" + run = helpers.fake_rnfs(self.victim, []) + + def without_private(argv, *args, **kwargs): + result = run(argv, *args, **kwargs) + if argv[1:] == ["status"]: + result.stdout = b"".join(line for line in result.stdout.splitlines(True) + if not line.startswith(b"private:")) + return result + + output = helpers.exec_display_page(without_private, self.notes, self.data_dir) + self.assertNotIn("members-only notes", output) + self.assertIn("isn't shared on this node", output) + + +class VisitorRateLimitTest(PageTestCase): + """Rate limits per identified visitor, for the others together, and for everyone.""" + + def setUp(self): + super().setUp() + self.cid = self.provide(CONTENT) # not shared: a view only lists pins + + def view_as(self, who: str | None) -> bool: + """One view of the page by visitor who (None: one who didn't identify); True if it was served.""" + environment = {"remote_identity": who} if who is not None else {} + with mock.patch.dict(os.environ, environment): + if who is None: + os.environ.pop("remote_identity", None) + output, calls = self.view(self.cid) + served = bool(calls) + self.assertEqual(served, "too many requests" not in output) + return served + + def test_one_identified_visitor_cannot_lock_out_the_others(self): + """30 views (the old shared limit) from one identified visitor leave room for everyone else.""" + for _ in range(30): + self.view_as(identity(1)) + self.assertTrue(self.view_as(None), "an anonymous visitor was locked out by one identified visitor") + self.assertTrue(self.view_as(identity(2)), "a second visitor was locked out by the first") + + def test_anonymous_visitors_cannot_lock_out_identified_ones(self): + for _ in range(self.page("RATE_LIMIT")): + self.assertTrue(self.view_as(None)) + self.assertFalse(self.view_as(None)) # those who don't identify share their limit + self.assertTrue(self.view_as(identity(1)), "an identified visitor was locked out by anonymous ones") + + def test_each_identified_visitor_has_a_limit(self): + """A guard on the new limit: VISITOR_LIMIT views, then refused, whatever the others did.""" + served = sum(self.view_as(identity(1)) for _ in range(self.page("VISITOR_LIMIT") + 5)) + self.assertEqual(served, self.page("VISITOR_LIMIT")) + + def test_all_visitors_together_are_still_bounded(self): + """A guard: new identities, which cost nothing, still meet TOTAL_LIMIT.""" + limit, total = self.page("VISITOR_LIMIT"), self.page("TOTAL_LIMIT") + served = sum(self.view_as(identity(n)) for n in range(total // limit + 2) for _ in range(limit)) + self.assertEqual(served, total) + + def test_malformed_identity_counts_as_anonymous(self): + for _ in range(self.page("RATE_LIMIT")): + self.view_as(None) + self.assertFalse(self.view_as("not-an-identity")) + self.assertFalse(self.view_as(identity(1) + "00")) + + +if __name__ == "__main__": + unittest.main() From 0c8d78114d48fd47747cd3c946835ad6e9c46f58 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 14:57:13 +1030 Subject: [PATCH 100/107] Document the Windows rnfs dir created private, the identity and settings checks, skipped hard links, and the page's private mode and per-visitor limits --- CHANGELOG.md | 24 +++++++++++++++++-- README.md | 14 +++++++++-- .../display-mu-file/README.md | 24 +++++++++++++------ design.md | 24 ++++++++++++++----- specification.md | 12 ++++++++-- 5 files changed, 79 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2786bc8..fae8531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,17 @@ Platform and packaging: An existing rnfs dir or identity file that lets others in is tightened (with a notice), the daemon refuses an rnfs dir owned by another account, and every command refuses a token file others can read, as on POSIX. A `--dir` outside `%USERPROFILE%` gets a warning. Not yet tested on Windows itself. +- On Windows a new rnfs dir and its subdirectories get that access list in the call that creates them. A new + `--dir` used to have its parent's access list until rnfs set its own, so another local account could put a + file in it first (an `rnfs.toml` naming a Reticulum config of theirs, whose interfaces can run programs, or + an identity); a new rnfs dir that already holds anything is now refused. `rnfs.toml` and the identity are + checked before use, through the handle they are read from: one owned by another account is refused, one + others may access is made private with a warning, and one that can't be made private stops rnfs (for the + identity this was a warning). An rnfs dir that is a junction or symlink is refused. Not yet tested on + Windows itself. +- The shared folder skips files that have other hard links, with a line in the log, also inside shared + directories: someone who may write to `files/` could hard link a file of yours from elsewhere on the same + disk into it and have it shared. A file is imported once its other links are gone. `add` is unchanged. - `install.sh` installs the latest release from its sdist (`rnfs-X.Y.Z.tar.gz`, a release file) instead of GitHub's archive of the tag, which neither `SHA256SUMS` nor the build provenance covers and which a moved tag would change unnoticed. The sdist must match the release's `SHA256SUMS`; where the GitHub CLI (2.49 or @@ -80,8 +91,17 @@ Platform and packaging: protection rules (set in the repository's settings). - The systemd user unit (`contrib/rnfs.service` and the one `install.sh` writes) adds `SystemCallFilter=@system-service` and `SystemCallArchitectures=native`. -- The identity file's permission fix no longer follows a symlink: a symlink at the identity path is left as - it is, with a warning, and the file is changed through a descriptor opened with `O_NOFOLLOW`. +- The identity file's permission fix no longer follows a symlink, and the daemon refuses to start, with an + error, if the identity file is a symlink, belongs to another user or isn't a regular file. A symlink used + to be loaded with a warning, and a FIFO there made the start hang. The file is checked, tightened and read + through one descriptor opened with `O_NOFOLLOW`, so a file swapped in after the check isn't the one loaded. + +Companion NomadNet pages: +- `display-mu-file` shows only allowlisted CIDs when the node runs in private mode: it used to show any + unsealed file the operator pinned (up to 200 KB) to every visitor who knew its CID. Its rate limit is now per + visitor where NomadNet says who that is: 10 requests a minute for each visitor who identified, 30 for all who + didn't together, and 60 for everyone. One visitor's requests used to lock everyone out for a minute. + Identities cost nothing to make, so someone who makes several can still reach the overall limit. Gateway privacy: - Errors from the node other than not-found (`400`, `413`, `422`, `503`, `507`, as well as `500`) get a fixed diff --git a/README.md b/README.md index 5dd84b4..624b9e1 100644 --- a/README.md +++ b/README.md @@ -359,13 +359,23 @@ Uninstalling never deletes it. The rnfs directory holds your keys and the API token, so rnfs keeps it private: readable by you only, and on Windows with an access list for you and SYSTEM only (also on another drive or under `C:\ProgramData`, where -Windows would otherwise let every local user read it). A `--dir` outside your user profile still gets a -warning on Windows, since accounts that can change its parent folder could replace it. +Windows would otherwise let every local user read it). On Windows a new rnfs directory gets that access list +as it is created, and the daemon refuses an rnfs directory that is a junction or symlink (give its target as +`--dir`) and a new one that something else has already put files in. A `--dir` outside your user profile +still gets a warning on Windows, since accounts that can change its parent folder could replace it. + +`rnfs.toml` and `storage/identity` must be your own: rnfs refuses to use either if it belongs to another +account, and makes them private to you (with a warning) if others could read them. The identity must be a +regular file, not a symlink: the daemon refuses to start otherwise. The shared folder must not be writable by accounts you don't trust. rnfs skips links inside it and checks every directory and file it opens there, but on Windows, where directories are walked by path, someone who can write to it gets a race: swapping a directory for a junction while it is scanned. The checks are made to win that race, but don't rely on them: keep `files/` (and anything you link it to) changeable only by you. +Files with more than one hard link are skipped too, with a line in the log, also inside shared directories: +such a file is also a file somewhere else, which someone who can write to `files/` could have linked there. +Copy a file into `files/` rather than hard linking it (or share it with `rnfs add`, which takes hard-linked +files). ## Sample files diff --git a/companion-nomadnet-sites/display-mu-file/README.md b/companion-nomadnet-sites/display-mu-file/README.md index 202eba5..dae255b 100644 --- a/companion-nomadnet-sites/display-mu-file/README.md +++ b/companion-nomadnet-sites/display-mu-file/README.md @@ -25,8 +25,10 @@ just because a visitor asked for it. It shows a file only if one of these is tru - **Its CID is in your allowlist** (see below). These are the only files the page fetches from the network if this node doesn't have them yet. -Files your pins share are shown to anyone who knows the CID, whatever `private` mode allows over RNFS -itself. Don't pin files there that you wouldn't put on the page. +If your node runs in `private` mode (members only), the page shows **only allowlisted CIDs**: RNFS shares your +pins with members only, and the page can't tell who is one. It asks `rnfs status` whether the node is private, +and treats an answer that doesn't say as private. Otherwise your pins are shown to anyone who knows the CID, so +don't pin files you wouldn't put on the page. The page also refuses: @@ -57,7 +59,7 @@ directory, so none of it is served. The page creates the directory with mode 070 | `allowlist.txt` | Your allowlist. You create and edit it. | | `page.log` | Errors, with details. Moved to `page.log.1` past 1 MB. | | `lock` | Lets only one request run at a time. | -| `rate.json` | Times of recent requests, for the rate limit. | +| `rate.json` | Times of recent requests (with the visitor's identity hash, if they identified), for the rate limits. | NomadNet runs pages with a minimal environment. `RNFS_PAGE_DIR` overrides the directory, but it only works when you run the page by hand, for example to test it: @@ -86,10 +88,18 @@ allowlisted file available, pin it yourself with `rnfs pin add `. ### Busy and rate limits Only one request runs at a time. A visitor who arrives while another request is running is asked to try -again in a moment. There are also at most `RATE_LIMIT` requests (30) in any `RATE_WINDOW` (60 s), counted -across all visitors. Requests with a malformed CID aren't counted, because they never reach rnfs. Both checks -fail closed: if the page can't use its data directory, it shows nothing. You can change these numbers at the -top of `index.mu`. +again in a moment. There are also rate limits, each over any `RATE_WINDOW` (60 s): + +- `VISITOR_LIMIT` (10) requests from each visitor who identified to your node. NomadNet tells the page who + that is (`remote_identity`) only when the visitor chose to identify on the link. +- `RATE_LIMIT` (30) requests from all visitors who didn't identify, together. Nothing tells them apart: + NomadNet's other value, `link_id`, is new with every link the visitor opens. +- `TOTAL_LIMIT` (60) requests from everyone. + +So one visitor can't use up everyone else's requests. Identities cost nothing to make, though: someone who +makes several can still reach `TOTAL_LIMIT`, and then every visitor waits for the window to pass. Requests +with a malformed CID aren't counted, because they never reach rnfs. All checks fail closed: if the page can't +use its data directory, it shows nothing. You can change these numbers at the top of `index.mu`. ### The log diff --git a/design.md b/design.md index 2ac3f10..d6b2825 100644 --- a/design.md +++ b/design.md @@ -280,11 +280,17 @@ and says why). The daemon writes a new random token to `/api_token` (mode 0600, in the 0700 rnfs dir) at each start and the CLI reads it, so other local users and processes can't drive the API. `_make_private_dirs` refuses to start (RnfsError) if the rnfs dir belongs to another user or can't be made 0700. On Windows (`rnfs/winacl.py`), mode bits set no ACL, so a new -rnfs dir gets a protected DACL allowing only the user and SYSTEM (inherited below it), an existing one that -allows anyone else is given it, and one owned by another account is refused; the token, identity and name key +rnfs dir and its subdirectories are created with a protected DACL allowing only the user and SYSTEM (inherited +below it) in the CreateDirectoryW call itself (`winacl.make_private_dir`, a self-relative descriptor built by +`private_descriptor`), so the parent's DACL never applies to them; a new root that already holds anything, and +a root that is a junction or symlink (`fsutil.is_link` on its lstat), are refused. An existing one that allows +anyone else is given the DACL, and one owned by another account is refused; the token, identity and name key files get the same DACL before they are renamed into place (`fsutil.write_atomic(private=True)`), and -`read_token` checks the opened token's DACL (Administrators tolerated). A `--dir` outside `%USERPROFILE%` is -warned about. The advapi32 calls are behind `winacl.backend`, which tests replace with a fake. A response to a request with +`read_token` checks the opened token's DACL (Administrators tolerated). `rnfs.toml` (`Config.load`, +`winacl.secure_settings`) and the identity (`network/identity.py`) are checked through the handle they are +read from (`winacl.tighten(fd=...)`): another account's is refused, a loose one tightened with a warning, and +a failure is fatal. A `--dir` outside `%USERPROFILE%` is warned about. The advapi32 and kernel32 calls are +behind `winacl.backend`, which tests replace with a fake. A response to a request with `X-Rnfs-Challenge: <32 hex>` carries `X-Rnfs-Proof: cli.api_proof(token, port, challenge)` (HMAC-SHA256 of `rnfs api proof `); `ApiClient.verify_daemon` checks it with `GET /api/status` before first sending the token. Both HTTP servers (`TimedHandler`) time out slow requests, cap concurrent connections (API @@ -636,8 +642,14 @@ Everything in the specification is implemented, with these simplifications and a at least `busy_delay`). A `Fetch` exiting with an error (or cancelled) closes the links of its requests still in flight. In private mode, incoming links not identified as a member's are closed first to make room. - The shared-folder scan and `add` walk directories by file descriptor (`O_NOFOLLOW`, `O_NONBLOCK`, type and - inode checks), so entries swapped for symlinks, FIFOs or devices mid-scan are skipped. Hard links inside the - shared folder are real entries and are shared. + inode checks), so entries swapped for symlinks, FIFOs or devices mid-scan are skipped. The scan (not `add`) + skips regular files whose opened descriptor has `st_nlink > 1` (`builder.hard_links`, `build_open`'s + `skip_hard_links`); such a file's signature is marked, so it is imported once its other links are gone. +- The identity file (`network/identity.py`) is opened with `O_NOFOLLOW | O_NONBLOCK` after an lstat that must + show a regular file, not a link; the descriptor must be that same regular file (device and inode), owned by + the user, and is tightened through `fchmod` if loose. The key is read from it (`RNS.Identity.from_bytes`), + never by path again. Anything else raises `IdentityFileError` (an RnfsError), which stops the daemon with + its message. - `get` (`walker.write_out`) skips and reports names the destination filesystem refuses to create (EINVAL, ENAMETOOLONG, EILSEQ: e.g. `?` on FAT, long names on eCryptfs) rather than failing the whole write-out. - On Windows (`rnfs/fsutil.py`, `IS_WINDOWS`): there is no execute bit, so `add` records every file as kind 0 diff --git a/specification.md b/specification.md index db0d280..ddaa37c 100644 --- a/specification.md +++ b/specification.md @@ -734,7 +734,9 @@ and repeat. **Shared folder.** Every `scan_interval`, files and directories in `/files/` are imported as with `add` (copied into the block store) and pinned with source `folder`. A changed or removed file releases its -old pin. +old pin. Unlike `add`, a scan MUST skip (with a log line) a regular file that has other hard links, at the +top level and inside shared directories: it is also a file elsewhere, which whoever may write to the folder +could have linked in. It is imported at the next scan after its other links are gone. **Archives** move content without a network (the equivalent of IPFS CAR files). - `export` fetches the target if needed, then writes a msgpack stream: `["rnfs-archive", 1, [root CIDs]]`, @@ -1079,7 +1081,13 @@ gateway can never reach the API. identity hash, LXMF address, traffic) needs the token like every other request. - The daemon MUST refuse to start if the rnfs directory belongs to another user or can't be made accessible to its owner only (on Windows: a protected access list allowing only the user and SYSTEM, which the files - below it inherit; Administrators are tolerated where an older version left them). + below it inherit; Administrators are tolerated where an older version left them). On Windows a new rnfs + directory and its subdirectories MUST get that access list when they are created (not after), the daemon + MUST refuse a new one that already holds anything, and one that is a junction or symlink. The settings file + (`rnfs.toml`) and the identity MUST be checked before use, through the handle they are read from: one owned + by another user (account) is refused, one others may access is made private (with a warning), and one that + can't be made private stops rnfs. The identity MUST also be a regular file (not a symlink, FIFO or device); + anything else stops the daemon with an error. - An API request carrying `X-Rnfs-Challenge: <32 hex digits>` is answered with `X-Rnfs-Proof: hex(HMAC-SHA256(token, "rnfs api proof "))`, `` being the API's. Before it first sends the token, the CLI checks this proof with a `GET /api/status`, so a program another From 12c4345d5b827198eee0608d67bd2896072cc2d3 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 15:17:57 +1030 Subject: [PATCH 101/107] Remove a leftover fragment from the README and point design.md at the CI workflow for the test commands --- README.md | 2 -- design.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 624b9e1..9e12275 100644 --- a/README.md +++ b/README.md @@ -923,8 +923,6 @@ rnfs --dir /tmp/a --api-port 9001 add somefile rnfs --dir /tmp/b --api-port 9002 get -o out ``` -ery thread's stack. - ## Known limitations - A name resolves only while its owner, a transport node that remembers its announce, or a keeper is diff --git a/design.md b/design.md index d6b2825..35f55c3 100644 --- a/design.md +++ b/design.md @@ -519,7 +519,7 @@ and a resumed job removes one left by a crash. ## Tests -How to run the tests is in [README.md, Tests](README.md#tests). Feature tests live in their own files +The commands CI runs the tests with are in `.github/workflows/tests.yml`. Feature tests live in their own files (`test_jobs.py`, `test_mesh.py`, `test_names_keep.py`, `test_contacts.py`, `test_seal.py`, `test_chunker.py`, `test_pin_false.py`, `test_gateway_formats.py`, `test_lxmf.py`), and `test_e2e.py` has a marked block per feature. CI (`.github/workflows/tests.yml`) runs them on Linux, macOS and Windows; `packaging/smoke_test.py` From f8ec0c6fd3a9f7e17a069fb50544512b0519e7d5 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 17:01:57 +1030 Subject: [PATCH 102/107] Remove Planned-companion-applications.md as it is no longer needed --- Planned-companion-applications.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 Planned-companion-applications.md diff --git a/Planned-companion-applications.md b/Planned-companion-applications.md deleted file mode 100644 index 390f630..0000000 --- a/Planned-companion-applications.md +++ /dev/null @@ -1,6 +0,0 @@ -## Nomadnet Gateway - - -## NomadNet File Index Page - -### Displays a list of the shared files \ No newline at end of file From f02927c3f6f9eec70db6e54ce99de17350fbe46a Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 17:08:01 +1030 Subject: [PATCH 103/107] Update roadmap.md for versioning and patch notes --- roadmap.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/roadmap.md b/roadmap.md index 1f7f8a5..19d90fb 100644 --- a/roadmap.md +++ b/roadmap.md @@ -10,6 +10,10 @@ DEFAULT_BOOTSTRAP_NODES global variable: node IDs of official, always-on DHT ser # 0.1.4 +Big fabel 5.5 audit and valnerability patch + +# 0.1.5 + ## CID denylist - Ability to point to a CID or name as a CID deny list @@ -18,7 +22,9 @@ DEFAULT_BOOTSTRAP_NODES global variable: node IDs of official, always-on DHT ser - Deny lists can contain file hashes and/or CIDs -## Migrate to JSON stores to sqlite3 +## Sqlite3 migration + +## Migrate to JSON stores to sqlite3 if they exist on startup - If JSON store exists, convert to the sqlite3 stor @@ -29,7 +35,7 @@ DEFAULT_BOOTSTRAP_NODES global variable: node IDs of official, always-on DHT ser * New file pinned by daemon -# 0.1.5 +# 0.1.6 ## Client Library @@ -37,7 +43,5 @@ DEFAULT_BOOTSTRAP_NODES global variable: node IDs of official, always-on DHT ser ## Per-app API tokens -## RNS API - * Destination.hash(identity, "rnfs", "api") * Whitelist and/or API key for auth \ No newline at end of file From 47d1b1515360b8034d68bcf35fcfa5e7ed29e870 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 17:26:00 +1030 Subject: [PATCH 104/107] Make the new security tests pass on Windows: compare logged paths across escaping, and skip the POSIX-only ones --- tests/security/test_l14_page_local_paths.py | 17 +++++++++++++---- .../security/test_l15_install_release_check.py | 1 + tests/security/test_l7_windows_acl.py | 1 + tests/test_identity.py | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/security/test_l14_page_local_paths.py b/tests/security/test_l14_page_local_paths.py index 44b3a5a..5376719 100644 --- a/tests/security/test_l14_page_local_paths.py +++ b/tests/security/test_l14_page_local_paths.py @@ -7,6 +7,7 @@ write the detail to page.log in their data dir; the tests check that too, for both pages. """ import os +import re import tempfile import unittest @@ -45,6 +46,14 @@ def log(self) -> str: with open(os.path.join(self.data_dir, "page.log"), encoding="utf-8") as f: return f.read() + def assertLogged(self, text: str): + """text is in the page's log. The pages log repr(message), which doubles a Windows path's backslashes + (twice over for an OSError's text, which holds the path's repr), so runs of path separators are + compared as one.""" + def squash(s: str) -> str: + return re.sub(r"[\\/]+", "/", s) + self.assertIn(squash(text), squash(self.log())) + def test_display_page_hides_cli_stderr_paths(self): """A failing rnfs command (`rnfs pin ls`, the page's first) must not leak its local path to the visitor.""" def run(argv, *args, **kwargs): @@ -54,7 +63,7 @@ def run(argv, *args, **kwargs): output = helpers.exec_display_page(run, CID, self.data_dir) self.assertNotIn(RNFS_DIR, output) self.assertIn("can't be shown right now", output) - self.assertIn(DAEMON_DOWN, self.log()) # the operator still gets the detail + self.assertLogged(DAEMON_DOWN) # the operator still gets the detail def test_display_page_hides_exceptions(self): """An rnfs that isn't installed (the OSError names its path) gives the same generic message.""" @@ -67,7 +76,7 @@ def run(argv, *args, **kwargs): output = helpers.exec_display_page(run, CID, self.data_dir) self.assertNotIn(missing, output) self.assertIn("can't be shown right now", output) - self.assertIn(missing, self.log()) + self.assertLogged(missing) def test_index_page_hides_cli_stderr_paths(self): """The file-index-node page, with the daemon down, shows a generic message and logs the detail.""" @@ -78,7 +87,7 @@ def run(argv, *args, **kwargs): output = helpers.exec_index_page(run, self.data_dir) self.assertNotIn(RNFS_DIR, output) self.assertIn("The file list isn't available right now", output) - self.assertIn(DAEMON_DOWN, self.log()) + self.assertLogged(DAEMON_DOWN) def test_log_is_kept_small(self): """Past LOG_MAX bytes the log moves to page.log.1, so visitors causing errors can't fill the disk.""" @@ -93,7 +102,7 @@ def run(argv, *args, **kwargs): helpers.exec_display_page(run, CID, self.data_dir) self.assertEqual(os.path.getsize(os.path.join(self.data_dir, "page.log.1")), log_max + 1) self.assertLess(len(self.log()), 1000) - self.assertIn(DAEMON_DOWN, self.log()) + self.assertLogged(DAEMON_DOWN) if __name__ == "__main__": diff --git a/tests/security/test_l15_install_release_check.py b/tests/security/test_l15_install_release_check.py index 3d6b2d3..b8a511a 100644 --- a/tests/security/test_l15_install_release_check.py +++ b/tests/security/test_l15_install_release_check.py @@ -41,6 +41,7 @@ @unittest.skipUnless(shutil.which("sh"), "needs a POSIX shell") +@unittest.skipIf(os.name == "nt", "install.sh is the Linux and macOS route, and its file:// release needs POSIX paths") class ReleaseCheckTest(unittest.TestCase): """A fake release v9.9.9 (its sdist and SHA256SUMS) under a file:// REPO_URL.""" diff --git a/tests/security/test_l7_windows_acl.py b/tests/security/test_l7_windows_acl.py index 6a5a4b1..b08d5ca 100644 --- a/tests/security/test_l7_windows_acl.py +++ b/tests/security/test_l7_windows_acl.py @@ -275,6 +275,7 @@ def test_name_key_gets_a_private_dacl(self): self.assertEqual(winacl.problem(acl.read(os.path.join(self.config.names_dir, "site")), USER), None) +@unittest.skipIf(os.name == "nt", "runs the POSIX code path, which needs os.geteuid") class PosixUnchangedTest(unittest.TestCase): """Outside Windows nothing here calls the backend: the mode-bit checks stay what they were.""" diff --git a/tests/test_identity.py b/tests/test_identity.py index 1f6c75f..794f73f 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -64,7 +64,7 @@ def test_no_permission_fix_on_windows(self): # Windows' st_mode always shows group and others allowed: no chmod or warning at every start load_or_create_identity(self.path) os.chmod(self.path, 0o666) - with mock.patch.object(fsutil, "IS_WINDOWS", True), mock.patch("os.fchmod") as fchmod, \ + with mock.patch.object(fsutil, "IS_WINDOWS", True), mock.patch("os.fchmod", create=True) as fchmod, \ mock.patch("os.chmod") as chmod, mock.patch.object(identity_module, "log") as log: load_or_create_identity(self.path) fchmod.assert_not_called() From a604399ed2a4abfb71af2b3dedb9ec167ddc5ea6 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 17:26:00 +1030 Subject: [PATCH 105/107] Hold the remote pin fetches in test_remote_pins_waiting_are_capped so a fast failed lookup can't free a place --- tests/test_node.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_node.py b/tests/test_node.py index bc95b84..209118e 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -525,9 +525,20 @@ def counted(node_id, cids_, on_progress=None): self.assertEqual(list(self.a.storage.pins()), cids[:1]) def test_remote_pins_waiting_are_capped(self): - self.net.blocks = lambda node_id, cids, on_progress=None: time.sleep(0.2) or BlocksResult("failed", unsent=cids) - with mock.patch.object(node_module, "REMOTE_PIN_QUEUE", 3): + # The worker's fetches are held until all five /pin requests are in: these CIDs have no provider, so + # an unheld fetch can fail at its lookup and free a place between two requests on a slow machine + go = threading.Event() + real_fetch = self.a._remote_pin_fetch + + def held_fetch(cid, source): + """Wait for the test, then fetch as the node does.""" + go.wait(10) + return real_fetch(cid, source) + + with mock.patch.object(node_module, "REMOTE_PIN_QUEUE", 3), \ + mock.patch.object(self.a, "_remote_pin_fetch", held_fetch): accepted = [self.a.backend.remote_pin(IDENTITY, Cid.for_block(RAW, b"%d" % i), True) for i in range(5)] + go.set() self.assertEqual(accepted, [True, True, True, False, False]) self.assertTrue(self.wait_for(lambda: not self.a._remote_reserved.get(remote_source(IDENTITY)), 30)) From 161fb8a21687ce5b52b21fb90d04af34cb48a5e5 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 17:26:32 +1030 Subject: [PATCH 106/107] Set the version to 0.1.4 --- CHANGELOG.md | 6 +++++- README.md | 36 ++++++++++++++++++------------------ rnfs/__init__.py | 2 +- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae8531..96a85ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ [specification.md](specification.md) is the authority on behaviour. -## Unreleased +## 0.1.4 + +Security release from the fifth audit: fixes for denial of service against `/blocks` serving, unbounded downloads, +DHT keeper and provider-store takeover, name rollback, gateway privacy leaks, the companion pages, LXMF floods and +Windows permissions. No wire-format changes: 0.1.4 and 0.1.3 nodes work together. Fixes: - A fetch of a whole DAG (`get`, `pin add`, `export`, `dir set`/`dir rm`, a followed name's new target, gateway diff --git a/README.md b/README.md index 9e12275..df1449b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Different package options available at [latest release](https://github.com/afit2 **Windows:** -[`rnfs-0.1.3-windows-x64.exe`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-windows-x64.exe), saved as `rnfs.exe` +[`rnfs-0.1.4-windows-x64.exe`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-windows-x64.exe), saved as `rnfs.exe` somewhere on your PATH ([how](#windows)). **Linux:** @@ -65,7 +65,7 @@ Or installers for ([Debian & Ubuntu](#debian-and-ubuntu)). [Flatpak](#other-lin **macOS:** -`pipx install https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-py3-none-any.whl` ([details](#macos)). +`pipx install https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-py3-none-any.whl` ([details](#macos)). ### 2 - Start rnfs and leave it running in its own terminal: @@ -102,26 +102,26 @@ rnfs runs on Linux, macOS and Windows. The easiest way to install it is a file f | System | Download | Install | |---|---|---| -| Windows (64-bit) | [`rnfs-0.1.3-windows-x64.exe`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-windows-x64.exe) | [Windows](#windows) | -| Debian 12+, Ubuntu 24.04+, Raspberry Pi OS (64-bit) | [`rnfs_0.1.3_amd64.deb`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs_0.1.3_amd64.deb) or [`rnfs_0.1.3_arm64.deb`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs_0.1.3_arm64.deb) | [Debian and Ubuntu](#debian-and-ubuntu) | -| Other Linux (x86_64) | [`rnfs-0.1.3-x86_64.flatpak`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-x86_64.flatpak) | [Other Linux](#other-linux-flatpak) | -| macOS, or any system with Python 3.11+ | [`rnfs-0.1.3-py3-none-any.whl`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-py3-none-any.whl) | [macOS](#macos) | +| Windows (64-bit) | [`rnfs-0.1.4-windows-x64.exe`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-windows-x64.exe) | [Windows](#windows) | +| Debian 12+, Ubuntu 24.04+, Raspberry Pi OS (64-bit) | [`rnfs_0.1.4_amd64.deb`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs_0.1.4_amd64.deb) or [`rnfs_0.1.4_arm64.deb`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs_0.1.4_arm64.deb) | [Debian and Ubuntu](#debian-and-ubuntu) | +| Other Linux (x86_64) | [`rnfs-0.1.4-x86_64.flatpak`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-x86_64.flatpak) | [Other Linux](#other-linux-flatpak) | +| macOS, or any system with Python 3.11+ | [`rnfs-0.1.4-py3-none-any.whl`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-py3-none-any.whl) | [macOS](#macos) | Every package includes Reticulum and LXMF, gives you one `rnfs` command, and keeps your data in the same -places. The release also has the source archive (`rnfs-0.1.3.tar.gz`) and `SHA256SUMS`, to -[check your download](#checking-a-download). These links are for 0.1.3; for a newer version, take the +places. The release also has the source archive (`rnfs-0.1.4.tar.gz`) and `SHA256SUMS`, to +[check your download](#checking-a-download). These links are for 0.1.4; for a newer version, take the same files from the [latest release](https://github.com/afit21/rnfs/releases/latest). ### Windows -Download [`rnfs-0.1.3-windows-x64.exe`](https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-windows-x64.exe). It is a single file that +Download [`rnfs-0.1.4-windows-x64.exe`](https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-windows-x64.exe). It is a single file that includes Python, Reticulum and LXMF; nothing else needs installing. In PowerShell, from your download folder, put it on your PATH as `rnfs`: ```powershell $bin = "$env:USERPROFILE\.local\bin" New-Item -ItemType Directory -Force $bin | Out-Null -Move-Item .\rnfs-0.1.3-windows-x64.exe "$bin\rnfs.exe" -Force +Move-Item .\rnfs-0.1.4-windows-x64.exe "$bin\rnfs.exe" -Force Unblock-File "$bin\rnfs.exe" [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";$bin", "User") # open a new terminal, then: @@ -152,8 +152,8 @@ or newer. Ubuntu 22.04 ships Python 3.10, so it isn't supported; use the [instal there instead. Pick `amd64` or `arm64` to match `dpkg --print-architecture`: ```sh -curl -LO https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs_0.1.3_amd64.deb -sudo apt install ./rnfs_0.1.3_amd64.deb +curl -LO https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs_0.1.4_amd64.deb +sudo apt install ./rnfs_0.1.4_amd64.deb rnfs daemon # or run it in the background: systemctl --user enable --now rnfs # a systemd user service; logs: journalctl --user -u rnfs -f ``` @@ -172,9 +172,9 @@ For x86_64 Linux distributions without `.deb` support. The Flatpak uses the Flat `org.freedesktop.Platform` runtime: ```sh -curl -LO https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-x86_64.flatpak +curl -LO https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-x86_64.flatpak flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo -flatpak install --user rnfs-0.1.3-x86_64.flatpak +flatpak install --user rnfs-0.1.4-x86_64.flatpak flatpak run io.github.afit21.rnfs daemon alias rnfs='flatpak run io.github.afit21.rnfs' # add to ~/.bashrc to type just "rnfs" ``` @@ -202,7 +202,7 @@ and puts the `rnfs` command on your PATH. Homebrew also installs a recent Python ```sh brew install pipx pipx ensurepath # then open a new terminal -pipx install https://github.com/afit21/rnfs/releases/download/v0.1.3/rnfs-0.1.3-py3-none-any.whl # includes Reticulum and LXMF +pipx install https://github.com/afit21/rnfs/releases/download/v0.1.4/rnfs-0.1.4-py3-none-any.whl # includes Reticulum and LXMF rnfs daemon ``` @@ -228,11 +228,11 @@ Each release's `SHA256SUMS` lists the checksum of every file. Download it next t ```sh sha256sum -c SHA256SUMS --ignore-missing # Linux -shasum -a 256 rnfs-0.1.3-py3-none-any.whl # macOS: compare with the line in SHA256SUMS +shasum -a 256 rnfs-0.1.4-py3-none-any.whl # macOS: compare with the line in SHA256SUMS ``` ```powershell -Get-FileHash .\rnfs-0.1.3-windows-x64.exe # Windows: compare with the line in SHA256SUMS +Get-FileHash .\rnfs-0.1.4-windows-x64.exe # Windows: compare with the line in SHA256SUMS ``` A matching checksum shows the download is whole, not who made it: anyone able to replace a release's files @@ -241,7 +241,7 @@ repository's release workflow. Check it with the [GitHub CLI](https://cli.github logged in with `gh auth login`): ```sh -gh attestation verify rnfs_0.1.3_amd64.deb --repo afit21/rnfs +gh attestation verify rnfs_0.1.4_amd64.deb --repo afit21/rnfs ``` ### Other ways to install diff --git a/rnfs/__init__.py b/rnfs/__init__.py index 71048a0..314b8b4 100644 --- a/rnfs/__init__.py +++ b/rnfs/__init__.py @@ -11,4 +11,4 @@ cli.py command line: run a daemon, or talk to one through its API """ -__version__ = "0.1.3" +__version__ = "0.1.4" From a452ada9284db8eb04523c7650f8052f49ee9c67 Mon Sep 17 00:00:00 2001 From: Afi Date: Thu, 15 Oct 2026 17:59:10 +1030 Subject: [PATCH 107/107] Find the README's Checking a download section at any heading level --- tests/security/test_i2_packaging_hardening.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/security/test_i2_packaging_hardening.py b/tests/security/test_i2_packaging_hardening.py index 7e4d1e0..f00a763 100644 --- a/tests/security/test_i2_packaging_hardening.py +++ b/tests/security/test_i2_packaging_hardening.py @@ -40,8 +40,8 @@ class ReadmeTest(unittest.TestCase): def test_checking_a_download_mentions_attestations(self): text = read(os.path.join(REPO, "README.md")) - section = text[text.index("### Checking a download"):] - section = section[:section.index("\n### ", 1)] + # The section, at whatever heading level, up to the next heading + section = re.search(r"^#+ Checking a download\n(.*?)(?=^#)", text, re.M | re.S).group(1) self.assertIn("gh attestation verify", section) self.assertIn("--repo afit21/rnfs", section)