diff --git a/declearn/communication3/__init__.py b/declearn/communication3/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9629fa22621b73b2bc4b232f9f79aa169cbad676 --- /dev/null +++ b/declearn/communication3/__init__.py @@ -0,0 +1,20 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DecLearn v3 experimental network communication API.""" + +from . import server diff --git a/declearn/communication3/server/__init__.py b/declearn/communication3/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..70c7fe3a8d303a1bb0427750ffed4f1c6177acc6 --- /dev/null +++ b/declearn/communication3/server/__init__.py @@ -0,0 +1,20 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DecLearn v3 experimental network server endpoint code.""" + +from ._peers import PeerPolicy, PeersManager diff --git a/declearn/communication3/server/_peers.py b/declearn/communication3/server/_peers.py new file mode 100644 index 0000000000000000000000000000000000000000..73eda459e9bafb62cc647b0ef2b84f00960d9698 --- /dev/null +++ b/declearn/communication3/server/_peers.py @@ -0,0 +1,179 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Component to manage a network of peers for a communication server.""" + +import copy +import dataclasses +import secrets + +from typing import Dict, Optional, Set, Tuple + + +__all__ = [ + "PeersManager", + "PeerPolicy", +] + + +DEFAULT_TOKEN_NBYTES = 64 # Default number of bytes in user auth tokens. + + +def create_secret_token() -> str: + """Create a random secret string token.""" + return secrets.token_hex(nbytes=DEFAULT_TOKEN_NBYTES) + + +MOCK_TOKEN = create_secret_token() + + +class InvalidPeerPolicyError(Exception): + """Custom exception to document invalid PeerPolicy instances.""" + + +@dataclasses.dataclass +class PeerPolicy: + """Dataclass defining the visibility and reachability policy of a peer.""" + + authorized: Set[str] = dataclasses.field(default_factory=set) + blocked: Set[str] = dataclasses.field(default_factory=set) + visible: bool = True + # future: + # - distinguish visibility / reachability? + # - add web-of-trust features? (visible by peers of peers) + + def __post_init__(self) -> None: + self.check_validity() + + def check_validity(self) -> None: + """Raise if this policy is unvalid due to inner contradictions.""" + if not self.blocked.isdisjoint(self.authorized): + raise InvalidPeerPolicyError( + "Policy documents some peers as both authorized and blocked." + ) + + +@dataclasses.dataclass +class Peer: + """Proxy for a client in a communication network of peers.""" + + name: str + token: str = dataclasses.field(default_factory=create_secret_token) + policy: PeerPolicy = dataclasses.field(default_factory=PeerPolicy) + + def is_visible_by( + self, + peer: str, + ) -> bool: + """Check whether this peer is visible by a given peer.""" + if self.policy.visible: + return peer not in self.policy.blocked + return peer in self.policy.authorized + + +class PeersManager: + """Component to manage a network of peers for a communication server.""" + + def __init__( + self, + ) -> None: + self._peers = {} # type: Dict[str, Peer] + + def add_peer( + self, + name: str, + policy: Optional[PeerPolicy] = None, + ) -> Tuple[str, str]: + """Add a new peer to the network, and return its name and token.""" + if name in self._peers: + count = sum( + other.rsplit(".", 1)[0] == name for other in self._peers + ) + name = f"{name}.{count}" + self._peers[name] = peer = Peer(name, policy=policy or PeerPolicy()) + return name, peer.token + + def authenticate_peer( + self, + name: str, + token: str, + ) -> bool: + """Authenticate a peer based on their secret token.""" + if name in self._peers: + return secrets.compare_digest(token, self._peers[name].token) + # Perform a comparison, to avoid peer name leakage via a time attack. + secrets.compare_digest(token, MOCK_TOKEN) + return False + + def _get_peer( + self, + name: str, + ) -> Peer: + """Gather a peer by name, or raise a KeyError.""" + peer = self._peers.get(name, None) + if peer is None: + raise KeyError(f"Invalid peer name: {name}.") + return peer + + def remove_peer( + self, + name: str, + ) -> None: + """Remove a peer from the network.""" + self._get_peer(name) # raise if the peer does not exist + self._peers.pop(name) + + def get_peer_policy( + self, + name: str, + ) -> PeerPolicy: + """Access the visibility policy of a peer.""" + peer = self._get_peer(name) + return copy.deepcopy(peer.policy) + + def update_peer_policy( + self, + name: str, + policy: PeerPolicy, + ) -> None: + """Update the visibility policy of a peer.""" + peer = self._get_peer(name) + peer.policy = copy.deepcopy(policy) + + def get_peer_topology( + self, + name: str, + ) -> Set[str]: # future: Topology, with more details + """Return the network topology from the POV of a given peer.""" + peer = self._get_peer(name) + return { + name + for name, other in self._peers.items() + if (other is not peer) and other.is_visible_by(peer.name) + } + + def get_full_topology( + self, + ) -> Dict[str, Dict[str, bool]]: + """Return the network's adjacency matrix, as a nested dict.""" + matrix = {} + for name, peer in self._peers.items(): + matrix[name] = { + other.name: (other is peer) or other.is_visible_by(peer.name) + for other in self._peers.values() + } + return matrix diff --git a/test/communication3/server/test_peers.py b/test/communication3/server/test_peers.py new file mode 100644 index 0000000000000000000000000000000000000000..77f526206fcdf17ad840fada3202b3244517352f --- /dev/null +++ b/test/communication3/server/test_peers.py @@ -0,0 +1,110 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the PeersManager communication server component.""" + + +from declearn.communication3.server import PeersManager, PeerPolicy + + +class TestPeersManager: + """Unit tests for the PeersManager communication server component.""" + + def test_add_peer_to_empty_manager(self) -> None: + """Test that a peer may be added to an empty manager.""" + manager = PeersManager() + name, token = manager.add_peer("toto") + assert name == "toto" + assert isinstance(token, str) + + def test_add_peers_with_same_name(self) -> None: + """Test that two peers may be created from the same initial name.""" + manager = PeersManager() + name_a, token_a = manager.add_peer("toto") + name_b, token_b = manager.add_peer("toto") + assert name_a != name_b + assert name_a == "toto" + assert name_b.startswith(name_a) + assert token_a != token_b + + def test_authenticate_peer_with_proper_credentials(self) -> None: + """Test that peer authentication works with proper credentials.""" + manager = PeersManager() + name, token = manager.add_peer("toto") + assert manager.authenticate_peer(name, token) + + def test_authenticate_peer_with_wrong_token(self) -> None: + """Test that peer authentication works with wrongful token.""" + manager = PeersManager() + name, _ = manager.add_peer("toto") + assert not manager.authenticate_peer(name, "mock-token") + + def test_authenticate_peer_with_wrong_name(self) -> None: + """Test that peer authentication works with non-existent name.""" + manager = PeersManager() + assert not manager.authenticate_peer("toto", "mock-token") + + def test_remove_peer(self) -> None: + """Test that peer removal works.""" + # Set up a manager with a peer. + manager = PeersManager() + name, token = manager.add_peer("toto") + assert manager.authenticate_peer(name, token) + # Verify that the peer can be removed. + manager.remove_peer(name) + assert not manager.authenticate_peer(name, token) + + def test_set_get_update_peer_policy(self) -> None: + """Test that a peer's policy may be set, accessed and updated.""" + # Set up a peer with a base policy. + manager = PeersManager() + base_policy = PeerPolicy(authorized={"foo"}, blocked={"bar"}) + name, _ = manager.add_peer("toto", policy=base_policy) + # Test that the policy can be accessed and has been properly set. + assert manager.get_peer_policy(name) == base_policy + # Test that this policy can be updated. + new_policy = PeerPolicy(authorized={"bar"}, blocked={"foo"}) + manager.update_peer_policy(name, new_policy) + assert manager.get_peer_policy(name) == new_policy + + def test_get_peer_topology(self) -> None: + """That that peer-policy-based partial topology access works.""" + # Set up a manager with three peers, one of which is hidden. + manager = PeersManager() + manager.add_peer("foo", policy=PeerPolicy(visible=True)) + manager.add_peer("bar", policy=PeerPolicy(visible=False)) + manager.add_peer("buz", policy=PeerPolicy(visible=True)) + # Verify that visible topologies match expectations. + assert manager.get_peer_topology("foo") == {"buz"} + assert manager.get_peer_topology("bar") == {"foo", "buz"} + assert manager.get_peer_topology("buz") == {"foo"} + + def test_get_full_topology(self) -> None: + """That that peer-policy-based global topology access works.""" + # Set up a manager with three peers, one of which is hidden to others. + manager = PeersManager() + manager.add_peer("foo", policy=PeerPolicy(visible=True)) + manager.add_peer("bar", policy=PeerPolicy(visible=False)) + manager.add_peer("buz", policy=PeerPolicy(visible=True)) + # Verify that the global topology matches expectations. + expected = { + "foo": {"foo": True, "bar": False, "buz": True}, + "bar": {"foo": True, "bar": True, "buz": True}, + "buz": {"foo": True, "bar": False, "buz": True}, + } + topology = manager.get_full_topology() + assert topology == expected