Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(framework) Remove unused node auth arguments of SuperLink #4848

Merged
merged 8 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions e2e/e2e-bare-auth/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ mkdir -p $KEY_DIR

rm -f $KEY_DIR/*

ssh-keygen -t ecdsa -b 384 -N "" -f "${KEY_DIR}/server_credentials" -C ""

generate_client_credentials() {
local num_clients=${1:-2}
for ((i=1; i<=num_clients; i++))
Expand Down
4 changes: 1 addition & 3 deletions e2e/test_exec_api.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ esac
# Set authentication parameters
case "$2" in
client-auth)
server_auth='--auth-list-public-keys ../keys/client_public_keys.csv
--auth-superlink-private-key ../keys/server_credentials
--auth-superlink-public-key ../keys/server_credentials.pub'
server_auth='--auth-list-public-keys ../keys/client_public_keys.csv'
client_auth_1='--auth-supernode-private-key ../keys/client_credentials_1
--auth-supernode-public-key ../keys/client_credentials_1.pub'
client_auth_2='--auth-supernode-private-key ../keys/client_credentials_2
Expand Down
2 changes: 1 addition & 1 deletion e2e/test_superlink.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ case "$2" in
server_address="127.0.0.1:9092"
server_app_address="127.0.0.1:9091"
db_arg="--database :flwr-in-memory-state:"
server_auth="--auth-list-public-keys keys/client_public_keys.csv --auth-superlink-private-key keys/server_credentials --auth-superlink-public-key keys/server_credentials.pub"
server_auth="--auth-list-public-keys keys/client_public_keys.csv"
client_auth_1="--auth-supernode-private-key keys/client_credentials_1 --auth-supernode-public-key keys/client_credentials_1.pub"
client_auth_2="--auth-supernode-private-key keys/client_credentials_2 --auth-supernode-public-key keys/client_credentials_2.pub"
;;
Expand Down
7 changes: 2 additions & 5 deletions examples/flower-authentication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ The script also generates a CSV file that includes each of the generated (client
## Start the long-running Flower server (SuperLink)

Starting long-running Flower server component (SuperLink) and enable authentication is very easy; all you need to do is type
`--auth-list-public-keys` containing file path to the known `client_public_keys.csv`, `--auth-superlink-private-key`
containing file path to the SuperLink's private key `server_credentials`, and `--auth-superlink-public-key` containing file path to the SuperLink's public key `server_credentials.pub`. Notice that you can only enable authentication with a secure TLS connection.
`--auth-list-public-keys` containing file path to the known `client_public_keys.csv`. Notice that you can only enable authentication with a secure TLS connection.

Let's first launch the `SuperLink`:

Expand All @@ -91,9 +90,7 @@ flower-superlink \
--ssl-ca-certfile certificates/ca.crt \
--ssl-certfile certificates/server.pem \
--ssl-keyfile certificates/server.key \
--auth-list-public-keys keys/client_public_keys.csv \
--auth-superlink-private-key keys/server_credentials \
--auth-superlink-public-key keys/server_credentials.pub
--auth-list-public-keys keys/client_public_keys.csv
```

At this point your server-side is idling. Next, let's connect two `SuperNode`s, and then we'll start a run.
Expand Down
2 changes: 0 additions & 2 deletions examples/flower-authentication/generate_auth_keys.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ mkdir -p $KEY_DIR

rm -f $KEY_DIR/*

ssh-keygen -t ecdsa -b 384 -N "" -f "${KEY_DIR}/server_credentials" -C ""

generate_client_credentials() {
local num_clients=${1:-2}
for ((i=1; i<=num_clients; i++))
Expand Down
28 changes: 14 additions & 14 deletions framework/docs/source/how-to-authenticate-supernodes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
Authenticate SuperNodes
=======================

Flower has built-in support for authenticated SuperNodes that you can use to verify the
identities of each SuperNode connecting to a SuperLink. For increased security, node
authentication can only be used when encrypted connections (SSL/TLS) are enabled. Flower
node authentication works similar to how GitHub SSH authentication works:
Flower has built-in support for authenticated SuperNodes, allowing you to verify the
identity of each SuperNode connecting to a SuperLink. To enhance security, node
authentication is only available when encrypted connections (SSL/TLS) are enabled.

- SuperLink (server) stores a list of public keys of known SuperNodes (clients)
- Using ECDH, both SuperNode and SuperLink independently derive a shared secret
- Shared secret is used to compute the HMAC value of the message sent from SuperNode to
SuperLink as a token
- SuperLink verifies the token
Flower's node authentication leverages a signature-based mechanism to verify each node's
identity:

- Each SuperNode must already possess a unique Elliptic Curve (EC) public/private key
pair.
- The SuperLink (server) maintains a whitelist of EC public keys for all trusted
SuperNodes (clients).
- A SuperNode signs a timestamp with its private key and sends the signed timestamp to
the SuperLink.
- The SuperLink verifies the signature and timestamp using the SuperNode's public key.

.. note::

Expand Down Expand Up @@ -68,16 +72,12 @@ that the authentication feature can only be enabled in the presence of TLS.
--ssl-ca-certfile certificates/ca.crt \
--ssl-certfile certificates/server.pem \
--ssl-keyfile certificates/server.key \
--auth-list-public-keys keys/client_public_keys.csv \
--auth-superlink-private-key keys/server_credentials \
--auth-superlink-public-key keys/server_credentials.pub
--auth-list-public-keys keys/client_public_keys.csv

.. dropdown:: Understand the command

* ``--auth-list-public-keys``: Specify the path to a CSV file storing the public keys of all SuperNodes that should be allowed to connect with the SuperLink.
| A valid CSV file storing known node public keys should list the keys in OpenSSH format, separated by commas and without any comments. Refer to the code sample, which contains a CSV file with two known node public keys.
* | ``--auth-superlink-private-key``: the private key of the SuperLink.
* | ``--auth-superlink-public-key``: the public key of the SuperLink.

Enable node authentication in SuperNode
---------------------------------------
Expand Down
97 changes: 18 additions & 79 deletions src/py/flwr/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,8 @@

import grpc
import yaml
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import (
load_ssh_private_key,
load_ssh_public_key,
)
from cryptography.hazmat.primitives.serialization import load_ssh_public_key

from flwr.common import GRPC_MAX_MESSAGE_LENGTH, EventType, event
from flwr.common.address import parse_address
Expand Down Expand Up @@ -64,7 +60,6 @@
from flwr.common.grpc import generic_create_grpc_server
from flwr.common.logger import log, warn_deprecated_feature
from flwr.common.secure_aggregation.crypto.symmetric_encryption import (
private_key_to_bytes,
public_key_to_bytes,
)
from flwr.proto.fleet_pb2_grpc import ( # pylint: disable=E0611
Expand Down Expand Up @@ -378,21 +373,12 @@ def run_superlink() -> None:
fleet_thread.start()
bckg_threads.append(fleet_thread)
elif args.fleet_api_type == TRANSPORT_TYPE_GRPC_RERE:
maybe_keys = _try_setup_node_authentication(args, certificates)
node_public_keys = _try_load_public_keys_node_authentication(args)
interceptors: Optional[Sequence[grpc.ServerInterceptor]] = None
if maybe_keys is not None:
(
node_public_keys,
server_private_key,
server_public_key,
) = maybe_keys
if node_public_keys is not None:
state = state_factory.state()
state.clear_supernode_auth_keys_and_credentials()
state.clear_supernode_auth_keys()
state.store_node_public_keys(node_public_keys)
state.store_server_private_public_key(
private_key_to_bytes(server_private_key),
public_key_to_bytes(server_public_key),
)
log(
INFO,
"Node authentication enabled with %d known public keys",
Expand Down Expand Up @@ -541,34 +527,20 @@ def _format_address(address: str) -> tuple[str, str, int]:
return (f"[{host}]:{port}" if is_v6 else f"{host}:{port}", host, port)


def _try_setup_node_authentication(
def _try_load_public_keys_node_authentication(
args: argparse.Namespace,
certificates: Optional[tuple[bytes, bytes, bytes]],
) -> Optional[tuple[set[bytes], ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey]]:
if (
not args.auth_list_public_keys
and not args.auth_superlink_private_key
and not args.auth_superlink_public_key
):
return None

if (
not args.auth_list_public_keys
or not args.auth_superlink_private_key
or not args.auth_superlink_public_key
):
sys.exit(
"Authentication requires providing file paths for "
"'--auth-list-public-keys', '--auth-superlink-private-key' and "
"'--auth-superlink-public-key'. Provide all three to enable authentication."
) -> Optional[set[bytes]]:
"""Return a set of node public keys."""
if args.auth_superlink_private_key or args.auth_superlink_public_key:
log(
WARN,
"The `--auth-superlink-private-key` and `--auth-superlink-public-key` "
"arguments are deprecated and will be removed in a future release. Node "
"authentication no longer requires these arguments.",
)

if certificates is None:
sys.exit(
"Authentication requires secure connections. "
"Please provide certificate paths to `--ssl-certfile`, "
"`--ssl-keyfile`, and `—-ssl-ca-certfile` and try again."
)
if not args.auth_list_public_keys:
return None

node_keys_file_path = Path(args.auth_list_public_keys)
if not node_keys_file_path.exists():
Expand All @@ -581,35 +553,6 @@ def _try_setup_node_authentication(

node_public_keys: set[bytes] = set()

try:
ssh_private_key = load_ssh_private_key(
Path(args.auth_superlink_private_key).read_bytes(),
None,
)
if not isinstance(ssh_private_key, ec.EllipticCurvePrivateKey):
raise ValueError()
except (ValueError, UnsupportedAlgorithm):
sys.exit(
"Error: Unable to parse the private key file in "
"'--auth-superlink-private-key'. Authentication requires elliptic "
"curve private and public key pair. Please ensure that the file "
"path points to a valid private key file and try again."
)

try:
ssh_public_key = load_ssh_public_key(
Path(args.auth_superlink_public_key).read_bytes()
)
if not isinstance(ssh_public_key, ec.EllipticCurvePublicKey):
raise ValueError()
except (ValueError, UnsupportedAlgorithm):
sys.exit(
"Error: Unable to parse the public key file in "
"'--auth-superlink-public-key'. Authentication requires elliptic "
"curve private and public key pair. Please ensure that the file "
"path points to a valid public key file and try again."
)

with open(node_keys_file_path, newline="", encoding="utf-8") as csvfile:
reader = csv.reader(csvfile)
for row in reader:
Expand All @@ -623,11 +566,7 @@ def _try_setup_node_authentication(
"file. Please ensure that the CSV file path points to a valid "
"known SSH public keys files and try again."
)
return (
node_public_keys,
ssh_private_key,
ssh_public_key,
)
return node_public_keys


def _try_obtain_exec_auth_plugin(
Expand Down Expand Up @@ -840,12 +779,12 @@ def _add_args_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--auth-superlink-private-key",
type=str,
help="The SuperLink's private key (as a path str) to enable authentication.",
help="This argument is deprecated and will be removed in a future release.",
)
parser.add_argument(
"--auth-superlink-public-key",
type=str,
help="The SuperLink's public key (as a path str) to enable authentication.",
help="This argument is deprecated and will be removed in a future release.",
)


Expand Down
46 changes: 7 additions & 39 deletions src/py/flwr/server/server_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,7 @@
from typing import Optional

import numpy as np
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_ssh_private_key,
load_ssh_public_key,
)
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

from flwr.common import (
Code,
Expand All @@ -50,12 +42,11 @@
)
from flwr.common.secure_aggregation.crypto.symmetric_encryption import (
generate_key_pairs,
private_key_to_bytes,
public_key_to_bytes,
)
from flwr.server.client_manager import SimpleClientManager

from .app import _try_setup_node_authentication
from .app import _try_load_public_keys_node_authentication
from .client_proxy import ClientProxy
from .server import Server, evaluate_clients, fit_clients

Expand Down Expand Up @@ -207,22 +198,12 @@ def test_setup_node_auth() -> None: # pylint: disable=R0914
"""Test setup node authentication."""
# Prepare
_, first_public_key = generate_key_pairs()
private_key, public_key = generate_key_pairs()

server_public_key = public_key.public_bytes(
encoding=Encoding.OpenSSH, format=PublicFormat.OpenSSH
)
server_private_key = private_key.private_bytes(
Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption()
)
_, second_public_key = generate_key_pairs()

# Execute
with tempfile.TemporaryDirectory() as temp_dir:
# Initialize temporary files
node_keys_file_path = Path(temp_dir) / "node_keys.csv"
server_private_key_path = Path(temp_dir) / "server_private_key"
server_public_key_path = Path(temp_dir) / "server_public_key"

# Fill the files with relevant keys
with open(node_keys_file_path, "w", newline="", encoding="utf-8") as csvfile:
Expand All @@ -237,33 +218,20 @@ def test_setup_node_auth() -> None: # pylint: disable=R0914
).decode(),
]
)
server_public_key_path.write_bytes(server_public_key)
server_private_key_path.write_bytes(server_private_key)

# Mock argparse with `require-node-authentication`` flag
mock_args = argparse.Namespace(
auth_list_public_keys=str(node_keys_file_path),
auth_superlink_private_key=str(server_private_key_path),
auth_superlink_public_key=str(server_public_key_path),
auth_superlink_private_key="",
auth_superlink_public_key="",
)

# Run _try_setup_node_authentication
result = _try_setup_node_authentication(mock_args, (b"", b"", b""))

expected_private_key = load_ssh_private_key(server_private_key, None)
expected_public_key = load_ssh_public_key(server_public_key)
node_pks = _try_load_public_keys_node_authentication(mock_args)

# Assert
assert isinstance(expected_private_key, ec.EllipticCurvePrivateKey)
assert isinstance(expected_public_key, ec.EllipticCurvePublicKey)
assert result is not None
assert result[0] == {
assert node_pks is not None
assert node_pks == {
public_key_to_bytes(first_public_key),
public_key_to_bytes(second_public_key),
}
assert private_key_to_bytes(result[1]) == private_key_to_bytes(
expected_private_key
)
assert public_key_to_bytes(result[2]) == public_key_to_bytes(
expected_public_key
)
27 changes: 2 additions & 25 deletions src/py/flwr/server/superlink/linkstate/in_memory_linkstate.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ def __init__(self) -> None:
self.task_ins_id_to_task_res_id: dict[UUID, UUID] = {}

self.node_public_keys: set[bytes] = set()
self.server_public_key: Optional[bytes] = None
self.server_private_key: Optional[bytes] = None

self.lock = threading.RLock()

Expand Down Expand Up @@ -403,30 +401,9 @@ def create_run(
log(ERROR, "Unexpected run creation failure.")
return 0

def store_server_private_public_key(
self, private_key: bytes, public_key: bytes
) -> None:
"""Store `server_private_key` and `server_public_key` in the link state."""
def clear_supernode_auth_keys(self) -> None:
"""Clear stored `node_public_keys` in the link state if any."""
with self.lock:
if self.server_private_key is None and self.server_public_key is None:
self.server_private_key = private_key
self.server_public_key = public_key
else:
raise RuntimeError("Server private and public key already set")

def get_server_private_key(self) -> Optional[bytes]:
"""Retrieve `server_private_key` in urlsafe bytes."""
return self.server_private_key

def get_server_public_key(self) -> Optional[bytes]:
"""Retrieve `server_public_key` in urlsafe bytes."""
return self.server_public_key

def clear_supernode_auth_keys_and_credentials(self) -> None:
"""Clear stored `node_public_keys` and credentials in the link state if any."""
with self.lock:
self.server_private_key = None
self.server_public_key = None
self.node_public_keys.clear()

def store_node_public_keys(self, public_keys: set[bytes]) -> None:
Expand Down
Loading