diff --git a/.github/workflows/pki-certs-location.yml b/.github/workflows/pki-certs-location.yml new file mode 100644 index 0000000000..d24be212a2 --- /dev/null +++ b/.github/workflows/pki-certs-location.yml @@ -0,0 +1,34 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Test PKI certs location + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + os: ['ubuntu-latest', 'windows-latest'] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.8 + uses: actions/setup-python@v3 + with: + python-version: "3.8" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + - name: Test PKI certs location + run: | + python -m tests.github.test_pki_cert_location --agg-cert-path ~/.openfl/agg --agg-key-path ~/.openfl/agg --col1-cert-path ~/.openfl/col1/ --col1-key-path ~/.openfl/col1 --col2-cert-path ~/.openfl/col2 --col2-key-path ~/.openfl/col2/ diff --git a/.github/workflows/pki.yml b/.github/workflows/pki.yml index dd0f133583..2136065191 100644 --- a/.github/workflows/pki.yml +++ b/.github/workflows/pki.yml @@ -44,4 +44,5 @@ jobs: pip install . - name: Test PKI run: | - python tests/github/pki_wrong_cn.py \ No newline at end of file + python tests/github/pki_wrong_cn.py + diff --git a/docs/running_the_federation.rst b/docs/running_the_federation.rst index d507126fc0..8987e129fc 100644 --- a/docs/running_the_federation.rst +++ b/docs/running_the_federation.rst @@ -801,6 +801,7 @@ However, continue with the following procedure for details in creating a federat - Ensures each node in the federation has a valid public key infrastructure (PKI) certificate. - Distributes the workspace from the aggregator node to the other collaborator nodes. + - Optionally, select a subset of registered/certified collaborators to participate in the federation. By default, all certified collaborators will be added. `STEP 3: Start the Federation`_ @@ -952,12 +953,27 @@ Setting Up the Certificate Authority fx workspace certify + By default, all certificates are stored under :code:`WORKSPACE_PATH/cert` folder inside workspace. To store certificates elsewhere: + + .. code-block:: console + + fx workspace certify -c CERT_PATH -k KEY_PATH -cdir CERT_DIR + + where :code:`CERT_PATH` is the path where the CA signing certificate and the certificate chain will be stored for this node, + :code:`KEY_PATH` is where the CA signing key path will be stored and :code:`CERT_DIR` is the directory where rest of CA certificates (e.g. root CA cert and key) will reside. + 3. Run the aggregator certificate creation command, replacing :code:`AFQDN` with the actual `fully qualified domain name (FQDN) `_ for the aggregator node. .. code-block:: console fx aggregator generate-cert-request --fqdn AFQDN + To store certificate under :code:`CERT_PATH` and key under :code:`KEY_PATH`: + + .. code-block:: console + + fx aggregator generate-cert-request --fqdn AFQDN -c CERT_PATH -k KEY_PATH + .. note:: On Linux\*\, you can discover the FQDN with this command: @@ -986,6 +1002,11 @@ Setting Up the Certificate Authority fx aggregator certify --fqdn AFQDN + If :code:`CERT_PATH` and :code:`KEY_PATH` was used to store CA signing certificate and signing key respectively, specify the same path here: + + .. code-block:: console + + fx aggregator certify --fqdn AFQDN -c CERT_PATH -k KEY_PATH .. note:: @@ -997,15 +1018,15 @@ Setting Up the Certificate Authority 5. This node now has a signed security certificate as the aggregator for this new federation. You should have the following files. - +---------------------------+--------------------------------------------------+ - | File Type | Filename | - +===========================+==================================================+ - | Certificate chain | WORKSPACE.PATH/cert/cert_chain.crt | - +---------------------------+--------------------------------------------------+ - | Aggregator certificate | WORKSPACE.PATH/cert/server/agg_{AFQDN}.crt | - +---------------------------+--------------------------------------------------+ - | Aggregator key | WORKSPACE.PATH/cert/server/agg_{AFQDN}.key | - +---------------------------+--------------------------------------------------+ + +---------------------------+-----------------------------------------------------------------------------+ + | File Type | Filename | + +===========================+=============================================================================+ + | Certificate chain | WORKSPACE.PATH/cert/cert_chain.crt or CERT.PATH/cert_chain.crt | + +---------------------------+-----------------------------------------------------------------------------+ + | Aggregator certificate | WORKSPACE.PATH/cert/server/agg_{AFQDN}.crt or CERT.PATH/agg_{AFQDN}.crt | + +---------------------------+-----------------------------------------------------------------------------+ + | Aggregator key | WORKSPACE.PATH/cert/server/agg_{AFQDN}.key or KEY.PATH/agg_{AFQDN}.key | + +---------------------------+-----------------------------------------------------------------------------+ where **AFQDN** is the fully-qualified domain name of the aggregator node. @@ -1049,21 +1070,27 @@ Importing the Workspace fx collaborator generate-cert-request -n {COL_LABEL} + To store certs under :code:`CERT_PATH_COL/` and key under :code:`KEY_PATH_COL/` other than :code:`WORKSPACE_PATH/cert`: + + .. code-block:: console + + fx collaborator generate-cert-request -n {COL_LABEL} -c {CERT_PATH_COL} -k {KEY_PATH_COL} + + where **CERT_PATH_COL** is the path where collaborator certificates (client) will be stored and **KEY_PATH_COL** is where the collaborator key is stored. The creation script will also ask you to specify the path to the data. For this example, enter the integer that represents which MNIST shard to use on this collaborator node. For the first collaborator node enter **1**. For the second collaborator node enter **2**. This will create the following files: - +-----------------------------+--------------------------------------------------------+ - | File Type | Filename | - +=============================+========================================================+ - | Collaborator CSR | WORKSPACE.PATH/cert/client/col_{COL_LABEL}.csr | - +-----------------------------+--------------------------------------------------------+ - | Collaborator key | WORKSPACE.PATH/cert/client/col_{COL_LABEL}.key | - +-----------------------------+--------------------------------------------------------+ - | Collaborator CSR Package | WORKSPACE.PATH/col_{COL_LABEL}_to_agg_cert_request.zip | - +-----------------------------+--------------------------------------------------------+ - + +-----------------------------+-------------------------------------------------------------------------------------+ + | File Type | Filename | + +=============================+=====================================================================================+ + | Collaborator CSR | WORKSPACE.PATH/cert/client/col_{COL_LABEL}.csr or CERT.PATH.COL/col_{COL_LABEL}.csr | + +-----------------------------+-------------------------------------------------------------------------------------+ + | Collaborator key | WORKSPACE.PATH/cert/client/col_{COL_LABEL}.key or KEY.PATH.COL/col_{COL_LABEL}.key | + +-----------------------------+-------------------------------------------------------------------------------------+ + | Collaborator CSR Package | WORKSPACE.PATH/col_{COL_LABEL}_to_agg_cert_request.zip | + +-----------------------------+-------------------------------------------------------------------------------------+ 4. On the aggregator node (i.e., the certificate authority in this example), sign the Collaborator CSR Package from the collaborator nodes. @@ -1073,6 +1100,12 @@ Importing the Workspace where :code:`/PATH/TO/col_{COL_LABEL}_to_agg_cert_request.zip` is the path to the Collaborator CSR Package containing the :code:`.csr` file from the collaborator node. The certificate authority will sign this certificate for use in the federation. + If :code:`CERT_PATH` and :code:`KEY_PATH` was used at the aggregator node to store CA signing certificate and signing key, specify the same path here: + + .. code-block:: console + + fx collaborator certify --request-pkg /PATH/TO/col_{COL_LABEL}_to_agg_cert_request.zip -n collaborator_name -c CERT_PATH -k KEY_PATH + The command packages the signed collaborator certificate, along with the **cert_chain.crt** file needed to verify certificate signatures, for transport back to the collaborator node: +---------------------------------+------------------------------------------------------------+ @@ -1087,7 +1120,33 @@ Importing the Workspace fx collaborator certify --import /PATH/TO/agg_to_col_{COL_LABEL}_signed_cert.zip + If :code:`CERT_PATH_COL` and :code:`KEY_PATH_COL` was used to store collaborator certificate and key for this node, specify the paths here: + + .. code-block:: console + + fx collaborator certify --import /PATH/TO/agg_to_col_{COL_LABEL}_signed_cert.zip -c CERT_PATH_COL -k KEY_PATH_COL + +.. _select_participants: +**On the Aggregator Node:** + +OPTIONAL STEP: Users can select participants to take part in a federation. By default, all the participants (Collaborators) certified by the CA are added to the plan :code:`plan/cols.yaml` file. To select only a subset of participants out of the displayed names of all certified collaborators for the current federation: + + .. code-block:: console + + fx workspace participants + +If :code:`CERT_PATH` was used at the aggregator node to store CA signing certificate, specify the same path here: + + .. code-block:: console + + fx workspace participants -c CERT_PATH + +If users want to add uncertified participants, or manually want to edit the collaborator names for the experiment: + + .. code-block:: console + + fx workspace participants --no_cert .. _running_the_federation.start_nodes: @@ -1103,6 +1162,12 @@ STEP 3: Start the Federation fx aggregator start + If :code:`CERT_PATH` and :code:`KEY_PATH` was used to store certificates for this node, specify the same path here: + + .. code-block:: console + + fx aggregator start -c ${CERT_PATH} -k ${KEY_PATH} + Now, the Aggregator is running and waiting for Collaborators to connect. .. _running_collaborators: @@ -1119,6 +1184,12 @@ STEP 3: Start the Federation where :code:`COLLABORATOR_LABEL` is the label for this Collaborator. + If :code:`CERT_PATH_COL` and :code:`KEY_PATH_COL` was used to store certificates for this node, specify the same path here: + + .. code-block:: console + + fx collaborator start -n {COLLABORATOR_LABEL} -c ${CERT_PATH_COL} -k ${KEY_PATH_COL} + .. note:: Each workspace may have multiple FL plans and multiple collaborator lists associated with it. @@ -1160,6 +1231,13 @@ Another way to access the trained model is by calling the API command directly f In fact, the :code:`get_model()` method returns a **TaskRunner** object loaded with the chosen model snapshot. Users may utilize the linked model as a regular Python object. +If :code:`CERT_PATH` was used to store certificates for any node, uninstall them: + +.. code-block:: console + + fx workspace uninstall-cert -c ${CERT_PATH} -k ${KEY_PATH} + fx aggregator uninstall-cert -c ${CERT_PATH} -k ${KEY_PATH} + fx collaborator uninstall-cert -c ${CERT_PATH_COL} -k ${KEY_PATH_COL} .. _running_the_federation_docker: diff --git a/openfl/federated/plan/plan.py b/openfl/federated/plan/plan.py index fbafb6a449..a5c51b945a 100644 --- a/openfl/federated/plan/plan.py +++ b/openfl/federated/plan/plan.py @@ -498,11 +498,13 @@ def get_collaborator(self, collaborator_name, root_certificate=None, private_key def get_client(self, collaborator_name, aggregator_uuid, federation_uuid, root_certificate=None, private_key=None, certificate=None): """Get gRPC client for the specified collaborator.""" + from openfl.interface.cli_helper import CERT_DIR + common_name = collaborator_name if not root_certificate or not private_key or not certificate: - root_certificate = 'cert/cert_chain.crt' - certificate = f'cert/client/col_{common_name}.crt' - private_key = f'cert/client/col_{common_name}.key' + root_certificate = f'{CERT_DIR}/cert_chain.crt' + certificate = f'{CERT_DIR}/client/col_{common_name}.crt' + private_key = f'{CERT_DIR}/client/col_{common_name}.key' client_args = self.config['network'][SETTINGS] @@ -522,12 +524,14 @@ def get_client(self, collaborator_name, aggregator_uuid, federation_uuid, def get_server(self, root_certificate=None, private_key=None, certificate=None, **kwargs): """Get gRPC server of the aggregator instance.""" + from openfl.interface.cli_helper import CERT_DIR + common_name = self.config['network'][SETTINGS]['agg_addr'].lower() if not root_certificate or not private_key or not certificate: - root_certificate = 'cert/cert_chain.crt' - certificate = f'cert/server/agg_{common_name}.crt' - private_key = f'cert/server/agg_{common_name}.key' + root_certificate = f'{CERT_DIR}/cert_chain.crt' + certificate = f'{CERT_DIR}/server/agg_{common_name}.crt' + private_key = f'{CERT_DIR}/server/agg_{common_name}.key' server_args = self.config['network'][SETTINGS] diff --git a/openfl/interface/aggregator.py b/openfl/interface/aggregator.py index a11122fcb6..5076187164 100644 --- a/openfl/interface/aggregator.py +++ b/openfl/interface/aggregator.py @@ -31,12 +31,16 @@ def aggregator(context): help='Federated learning plan [plan/plan.yaml]', default='plan/plan.yaml', type=ClickPath(exists=True)) -@option('-c', '--authorized_cols', required=False, +@option('-col', '--authorized_cols', required=False, help='Authorized collaborator list [plan/cols.yaml]', default='plan/cols.yaml', type=ClickPath(exists=True)) @option('-s', '--secure', required=False, help='Enable Intel SGX Enclave', is_flag=True, default=False) -def start_(plan, authorized_cols, secure): +@option('-c', '--cert_path', + help='The path where aggregator certificate resides', required=False) +@option('-k', '--key_path', + help='The path where aggregator key resides', required=False) +def start_(plan, authorized_cols, secure, cert_path, key_path): """Start the aggregator service.""" from pathlib import Path @@ -54,7 +58,20 @@ def start_(plan, authorized_cols, secure): logger.info('🧿 Starting the Aggregator Service.') - plan.get_server().serve() + if cert_path and key_path: + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + if not Path(cert_path).exists() or not Path(key_path).exists(): + echo(style('Certificate Path not found.', fg='red') + + ' Please run `fx aggregator generate-cert-request --cert_path`' + ' to generate certs under this directory first.') + + common_name = plan.config['network']['settings']['agg_addr'].lower() + plan.get_server(root_certificate=f'{cert_path}/cert_chain.crt', + private_key=f'{key_path}/agg_{common_name}.key', + certificate=f'{cert_path}/agg_{common_name}.crt').serve() + else: + plan.get_server().serve() @aggregator.command(name='generate-cert-request') @@ -62,12 +79,17 @@ def start_(plan, authorized_cols, secure): help=f'The fully qualified domain name of' f' aggregator node [{getfqdn_env()}]', default=getfqdn_env()) -def _generate_cert_request(fqdn): - generate_cert_request(fqdn) +@option('-c', '--cert_path', + help='The path where aggregator certificate will reside', required=False) +@option('-k', '--key_path', + help='The path where aggregator key will reside', required=False) +def _generate_cert_request(fqdn, cert_path, key_path): + generate_cert_request(fqdn, cert_path, key_path) -def generate_cert_request(fqdn): +def generate_cert_request(fqdn, cert_path=None, key_path=None): """Create aggregator certificate key pair.""" + from pathlib import Path from openfl.cryptography.participant import generate_csr from openfl.cryptography.io import write_crt from openfl.cryptography.io import write_key @@ -86,14 +108,29 @@ def generate_cert_request(fqdn): server_private_key, server_csr = generate_csr(common_name, server=True) - (CERT_DIR / 'server').mkdir(parents=True, exist_ok=True) + if cert_path and key_path: + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + + echo(' Writing AGGREGATOR certificate to: ' + style( + f'{cert_path}', fg='green')) + echo(' Writing AGGREGATOR key to: ' + style( + f'{key_path}', fg='green')) + + # Write aggregator csr and key to disk + write_crt(server_csr, cert_path / f'{file_name}.csr') + write_key(server_private_key, key_path / f'{file_name}.key') + else: + if cert_path and not key_path or not cert_path and key_path: + echo(f'Both cert_path and key_path should be provided. Using default {CERT_DIR}.') + (CERT_DIR / 'server').mkdir(parents=True, exist_ok=True) - echo(' Writing AGGREGATOR certificate key pair to: ' + style( - f'{CERT_DIR}/server', fg='green')) + echo(' Writing AGGREGATOR certificate key pair to: ' + style( + f'{CERT_DIR}/server', fg='green')) - # Write aggregator csr and key to disk - write_crt(server_csr, CERT_DIR / 'server' / f'{file_name}.csr') - write_key(server_private_key, CERT_DIR / 'server' / f'{file_name}.key') + # Write aggregator csr and key to disk + write_crt(server_csr, CERT_DIR / 'server' / f'{file_name}.csr') + write_key(server_private_key, CERT_DIR / 'server' / f'{file_name}.key') # TODO: function not used @@ -113,11 +150,15 @@ def find_certificate_name(file_name): help=f'The fully qualified domain name of aggregator node [{getfqdn_env()}]', default=getfqdn_env()) @option('-s', '--silent', help='Do not prompt', is_flag=True) -def _certify(fqdn, silent): - certify(fqdn, silent) +@option('-c', '--cert_path', + help='The path where signing CA certificate resides', required=False) +@option('-k', '--key_path', + help='The path where signing CA key resides', required=False) +def _certify(fqdn, silent, cert_path, key_path): + certify(fqdn, silent, cert_path, key_path) -def certify(fqdn, silent): +def certify(fqdn, silent, cert_path=None, key_path=None): """Sign/certify the aggregator certificate key pair.""" from pathlib import Path @@ -135,59 +176,141 @@ def certify(fqdn, silent): common_name = f'{fqdn}'.lower() file_name = f'agg_{common_name}' - cert_name = f'server/{file_name}' - signing_key_path = 'ca/signing-ca/private/signing-ca.key' - signing_crt_path = 'ca/signing-ca.crt' # Load CSR - csr_path_absolute_path = Path(CERT_DIR / f'{cert_name}.csr').absolute() - if not csr_path_absolute_path.exists(): - echo(style('Aggregator certificate signing request not found.', fg='red') - + ' Please run `fx aggregator generate-cert-request`' - ' to generate the certificate request.') + if cert_path and key_path: + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + + agg_cert_name = f'{file_name}' + csr_path_absolute_path = Path(cert_path / f'{agg_cert_name}.csr').absolute() + + if not csr_path_absolute_path.exists(): + echo(style('Aggregator certificate signing request not found.', fg='red') + + ' Please run `fx aggregator generate-cert-request -c -k`' + ' to generate the certificate request.') + + csr, csr_hash = read_csr(csr_path_absolute_path) - csr, csr_hash = read_csr(csr_path_absolute_path) + # Load private signing key + signing_key_path = 'signing-ca.key' + private_sign_key_absolute_path = Path(cert_path / signing_key_path).absolute() + if not private_sign_key_absolute_path.exists(): + echo(style('Signing key not found.', fg='red') + + ' Please run `fx workspace certify -c -k`' + ' to initialize the local certificate authority.') - # Load private signing key - private_sign_key_absolute_path = Path(CERT_DIR / signing_key_path).absolute() - if not private_sign_key_absolute_path.exists(): - echo(style('Signing key not found.', fg='red') - + ' Please run `fx workspace certify`' - ' to initialize the local certificate authority.') + signing_key = read_key(private_sign_key_absolute_path) - signing_key = read_key(private_sign_key_absolute_path) + # Load signing cert + signing_crt_path = 'signing-ca.crt' + signing_crt_absolute_path = Path(cert_path / signing_crt_path).absolute() + if not signing_crt_absolute_path.exists(): + echo(style('Signing certificate not found.', fg='red') + + ' Please run `fx workspace certify -c -k`' + ' to initialize the local certificate authority.') - # Load signing cert - signing_crt_absolute_path = Path(CERT_DIR / signing_crt_path).absolute() - if not signing_crt_absolute_path.exists(): - echo(style('Signing certificate not found.', fg='red') - + ' Please run `fx workspace certify`' - ' to initialize the local certificate authority.') + signing_crt = read_crt(signing_crt_absolute_path) - signing_crt = read_crt(signing_crt_absolute_path) + echo('The CSR Hash for file ' + + style(f'{agg_cert_name}.csr', fg='green') + + ' = ' + + style(f'{csr_hash}', fg='red')) - echo('The CSR Hash for file ' - + style(f'{cert_name}.csr', fg='green') - + ' = ' - + style(f'{csr_hash}', fg='red')) + crt_path_absolute_path = Path(cert_path / f'{agg_cert_name}.crt').absolute() - crt_path_absolute_path = Path(CERT_DIR / f'{cert_name}.crt').absolute() + if silent: - if silent: + echo(' Signing AGGREGATOR certificate') + signed_agg_cert = sign_certificate(csr, signing_key, signing_crt.subject) + write_crt(signed_agg_cert, crt_path_absolute_path) + + else: + + if confirm('Do you want to sign this certificate?'): - echo(' Signing AGGREGATOR certificate') - signed_agg_cert = sign_certificate(csr, signing_key, signing_crt.subject) - write_crt(signed_agg_cert, crt_path_absolute_path) + echo(' Signing AGGREGATOR certificate') + signed_agg_cert = sign_certificate(csr, signing_key, signing_crt.subject) + write_crt(signed_agg_cert, crt_path_absolute_path) + + else: + echo(style('Not signing certificate.', fg='red') + + ' Please check with this AGGREGATOR to get the correct' + ' certificate for this federation.') else: + agg_cert_name = f'server/{file_name}' + signing_key_path = 'ca/signing-ca/private/signing-ca.key' + signing_crt_path = 'ca/signing-ca.crt' + csr_path_absolute_path = Path(CERT_DIR / f'{agg_cert_name}.csr').absolute() + if not csr_path_absolute_path.exists(): + echo(style('Aggregator certificate signing request not found.', fg='red') + + ' Please run `fx aggregator generate-cert-request`' + ' to generate the certificate request.') + + csr, csr_hash = read_csr(csr_path_absolute_path) - if confirm('Do you want to sign this certificate?'): + # Load private signing key + private_sign_key_absolute_path = Path(CERT_DIR / signing_key_path).absolute() + if not private_sign_key_absolute_path.exists(): + echo(style('Signing key not found.', fg='red') + + ' Please run `fx workspace certify`' + ' to initialize the local certificate authority.') + + signing_key = read_key(private_sign_key_absolute_path) + + # Load signing cert + signing_crt_absolute_path = Path(CERT_DIR / signing_crt_path).absolute() + if not signing_crt_absolute_path.exists(): + echo(style('Signing certificate not found.', fg='red') + + ' Please run `fx workspace certify`' + ' to initialize the local certificate authority.') + + signing_crt = read_crt(signing_crt_absolute_path) + + echo('The CSR Hash for file ' + + style(f'{agg_cert_name}.csr', fg='green') + + ' = ' + + style(f'{csr_hash}', fg='red')) + + crt_path_absolute_path = Path(CERT_DIR / f'{agg_cert_name}.crt').absolute() + + if silent: echo(' Signing AGGREGATOR certificate') signed_agg_cert = sign_certificate(csr, signing_key, signing_crt.subject) write_crt(signed_agg_cert, crt_path_absolute_path) else: - echo(style('Not signing certificate.', fg='red') - + ' Please check with this AGGREGATOR to get the correct' - ' certificate for this federation.') + + if confirm('Do you want to sign this certificate?'): + + echo(' Signing AGGREGATOR certificate') + signed_agg_cert = sign_certificate(csr, signing_key, signing_crt.subject) + write_crt(signed_agg_cert, crt_path_absolute_path) + + else: + echo(style('Not signing certificate.', fg='red') + + ' Please check with this AGGREGATOR to get the correct' + ' certificate for this federation.') + + +@aggregator.command(name='uninstall-cert') +@option('-c', '--cert_path', + help='The cert path where pki certs reside', required=True) +@option('-k', '--key_path', + help='The key path where key reside', required=True) +def _uninstall_cert(cert_path, key_path): + """Uninstall cert/key pair under a given directory.""" + uninstall_cert(cert_path, key_path) + + +def uninstall_cert(cert_path=None, key_path=None): + """Uninstall certs under a given directory.""" + from openfl.utilities.utils import rmtree + from pathlib import Path + + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + rmtree(cert_path, ignore_errors=True) + rmtree(key_path, ignore_errors=True) diff --git a/openfl/interface/cli_helper.py b/openfl/interface/cli_helper.py index df304a4d36..3ecc5bda9f 100644 --- a/openfl/interface/cli_helper.py +++ b/openfl/interface/cli_helper.py @@ -19,7 +19,7 @@ WORKSPACE = SITEPACKS / 'openfl-workspace' TUTORIALS = SITEPACKS / 'openfl-tutorials' OPENFL_USERDIR = Path.home() / '.openfl' -CERT_DIR = Path('cert').absolute() +CERT_DIR = OPENFL_USERDIR / 'cert' def pretty(o): diff --git a/openfl/interface/collaborator.py b/openfl/interface/collaborator.py index 19972a2193..10144b8a41 100644 --- a/openfl/interface/collaborator.py +++ b/openfl/interface/collaborator.py @@ -37,7 +37,11 @@ def collaborator(context): help='The certified common name of the collaborator') @option('-s', '--secure', required=False, help='Enable Intel SGX Enclave', is_flag=True, default=False) -def start_(plan, collaborator_name, data_config, secure): +@option('-c', '--cert_path', + help='The path where collaborator certificate resides', required=False) +@option('-k', '--key_path', + help='The path where collaborator key resides', required=False) +def start_(plan, collaborator_name, data_config, secure, cert_path, key_path): """Start a collaborator service.""" from pathlib import Path @@ -58,7 +62,20 @@ def start_(plan, collaborator_name, data_config, secure): echo(f'Data = {plan.cols_data_paths}') logger.info('🧿 Starting a Collaborator Service.') - plan.get_collaborator(collaborator_name).run() + if cert_path and key_path: + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + if not Path(cert_path).exists() or not Path(key_path).exists(): + echo(style('Certificate Path not found.', fg='red') + + ' Please run `fx collaborator generate-cert-request -c -k`' + ' to generate certs under this directory first.') + common_name = f'{collaborator_name}'.lower() + plan.get_collaborator(collaborator_name, + root_certificate=f'{cert_path}/cert_chain.crt', + private_key=f'{key_path}/col_{common_name}.key', + certificate=f'{cert_path}/col_{common_name}.crt').run() + else: + plan.get_collaborator(collaborator_name).run() def register_data_path(collaborator_name, data_path=None, silent=False): @@ -117,21 +134,27 @@ def register_data_path(collaborator_name, data_path=None, silent=False): @option('-x', '--skip-package', help='Do not package the certificate signing request for export', is_flag=True) +@option('-c', '--cert_path', + help='The path where collaborator certificate resides', required=False) +@option('-k', '--key_path', + help='The path where collaborator key resides', required=False) def generate_cert_request_(collaborator_name, - data_path, silent, skip_package): + data_path, silent, skip_package, cert_path, key_path): """Generate certificate request for the collaborator.""" if data_path and is_directory_traversal(data_path): echo('Data path is out of the openfl workspace scope.') sys.exit(1) - generate_cert_request(collaborator_name, data_path, silent, skip_package) + generate_cert_request(collaborator_name, data_path, silent, skip_package, cert_path, key_path) -def generate_cert_request(collaborator_name, data_path, silent, skip_package): +def generate_cert_request(collaborator_name, data_path, silent, skip_package, + cert_path=None, key_path=None): """ Create collaborator certificate key pair. Then create a package with the CSR to send for signing. """ + from pathlib import Path from openfl.cryptography.participant import generate_csr from openfl.cryptography.io import write_crt from openfl.cryptography.io import write_key @@ -147,14 +170,27 @@ def generate_cert_request(collaborator_name, data_path, silent, skip_package): client_private_key, client_csr = generate_csr(common_name, server=False) - (CERT_DIR / 'client').mkdir(parents=True, exist_ok=True) + if cert_path and key_path: + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + + echo(' Moving COLLABORATOR certificate to: ' + style( + f'{cert_path}', fg='green')) + echo(' Moving COLLABORATOR key to: ' + style( + f'{key_path}', fg='green')) - echo(' Moving COLLABORATOR certificate to: ' + style( - f'{CERT_DIR}/{file_name}', fg='green')) + # Write collaborator csr and key to disk + write_crt(client_csr, cert_path / f'{file_name}.csr') + write_key(client_private_key, key_path / f'{file_name}.key') + else: + (CERT_DIR / 'client').mkdir(parents=True, exist_ok=True) + + echo(' Moving COLLABORATOR certificate key pair to: ' + style( + f'{CERT_DIR}', fg='green')) - # Write collaborator csr and key to disk - write_crt(client_csr, CERT_DIR / 'client' / f'{file_name}.csr') - write_key(client_private_key, CERT_DIR / 'client' / f'{file_name}.key') + # Write collaborator csr and key to disk + write_crt(client_csr, CERT_DIR / 'client' / f'{file_name}.csr') + write_key(client_private_key, CERT_DIR / 'client' / f'{file_name}.key') if not skip_package: from shutil import copytree @@ -177,7 +213,11 @@ def generate_cert_request(collaborator_name, data_path, silent, skip_package): ignore = ignore_patterns('__pycache__', '*.key', '*.srl', '*.pem') # Copy the current directory into the temporary directory - copytree(f'{CERT_DIR}/client', tmp_dir, ignore=ignore) + if cert_path: + cert_path = Path(cert_path).absolute() + copytree(cert_path, tmp_dir, ignore=ignore) + else: + copytree(f'{CERT_DIR}/client', tmp_dir, ignore=ignore) for f in glob(f'{tmp_dir}/*'): if common_name not in basename(f): @@ -261,12 +301,17 @@ def register_collaborator(file_name): @option('-i', '--import', 'import_', type=ClickPath(exists=True), help='Import the archive containing the collaborator\'s' ' certificate (signed by the CA)') -def certify_(collaborator_name, silent, request_pkg, import_): +@option('-c', '--cert_path', + help='The path where signing CA certificate resides', required=False) +@option('-k', '--key_path', + help='The path where signing CA key resides', required=False) +def certify_(collaborator_name, silent, request_pkg, import_, cert_path, key_path): """Certify the collaborator.""" - certify(collaborator_name, silent, request_pkg, import_) + certify(collaborator_name, silent, request_pkg, import_, cert_path, key_path) -def certify(collaborator_name, silent, request_pkg=None, import_=False): +def certify(collaborator_name, silent, request_pkg=None, import_=False, + cert_path=None, key_path=None): """Sign/certify collaborator certificate key pair.""" from click import confirm from pathlib import Path @@ -289,65 +334,166 @@ def certify(collaborator_name, silent, request_pkg=None, import_=False): common_name = f'{collaborator_name}'.lower() - if not import_: - if request_pkg: - Path(f'{CERT_DIR}/client').mkdir(parents=True, exist_ok=True) - unpack_archive(request_pkg, extract_dir=f'{CERT_DIR}/client') - csr = glob(f'{CERT_DIR}/client/*.csr')[0] - else: - if collaborator_name is None: - echo('collaborator_name can only be omitted if signing\n' - 'a zipped request package.\n' - '\n' - 'Example: fx collaborator certify --request-pkg ' - 'col_one_to_agg_cert_request.zip') - return - csr = glob(f'{CERT_DIR}/client/col_{common_name}.csr')[0] - copy(csr, CERT_DIR) - cert_name = splitext(csr)[0] - file_name = basename(cert_name) - signing_key_path = 'ca/signing-ca/private/signing-ca.key' - signing_crt_path = 'ca/signing-ca.crt' + if cert_path and key_path: + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() - # Load CSR - if not Path(f'{cert_name}.csr').exists(): - echo(style('Collaborator certificate signing request not found.', fg='red') - + ' Please run `fx collaborator generate-cert-request`' - ' to generate the certificate request.') + if not import_: + if request_pkg: + unpack_archive(request_pkg, extract_dir=cert_path) + csr = glob(f'{cert_path}/col_{common_name}.csr')[0] + else: + if collaborator_name is None: + echo('collaborator_name can only be omitted if signing\n' + 'a zipped request package.\n' + '\n' + 'Example: fx collaborator certify --request-pkg ' + 'col_one_to_agg_cert_request.zip') + return + csr = glob(f'{cert_path}/col_{common_name}.csr')[0] + copy(csr, cert_path) + cert_name = splitext(csr)[0] + file_name = basename(cert_name) + signing_key_path = 'signing-ca.key' + signing_crt_path = 'signing-ca.crt' + + # Load CSR + if not Path(f'{cert_name}.csr').exists(): + echo(style('Collaborator certificate signing request not found.', fg='red') + + ' Please run `fx collaborator generate-cert-request -c -k`' + ' to generate the certificate request.') + + csr, csr_hash = read_csr(f'{cert_name}.csr') + + # Load private signing key + if not Path(key_path / signing_key_path).exists(): + echo(style('Signing key not found.', fg='red') + + ' Please run `fx workspace certify`' + ' to initialize the local certificate authority.') + + signing_key = read_key(key_path / signing_key_path) + + # Load signing cert + if not Path(cert_path / signing_crt_path).exists(): + echo(style('Signing certificate not found.', fg='red') + + ' Please run `fx workspace certify`' + ' to initialize the local certificate authority.') + + signing_crt = read_crt(cert_path / signing_crt_path) + + echo('The CSR Hash for file ' + + style(f'{file_name}.csr', fg='green') + + ' = ' + + style(f'{csr_hash}', fg='red')) + + if silent: - csr, csr_hash = read_csr(f'{cert_name}.csr') + echo(' Signing COLLABORATOR certificate') + signed_col_cert = sign_certificate(csr, signing_key, signing_crt.subject) + write_crt(signed_col_cert, f'{cert_name}.crt') + register_collaborator(cert_path / f'{file_name}.crt') - # Load private signing key - if not Path(CERT_DIR / signing_key_path).exists(): - echo(style('Signing key not found.', fg='red') - + ' Please run `fx workspace certify`' - ' to initialize the local certificate authority.') + else: - signing_key = read_key(CERT_DIR / signing_key_path) + if confirm('Do you want to sign this certificate?'): - # Load signing cert - if not Path(CERT_DIR / signing_crt_path).exists(): - echo(style('Signing certificate not found.', fg='red') - + ' Please run `fx workspace certify`' - ' to initialize the local certificate authority.') + echo(' Signing COLLABORATOR certificate') + signed_col_cert = sign_certificate(csr, signing_key, signing_crt.subject) + write_crt(signed_col_cert, f'{cert_name}.crt') + register_collaborator(cert_path / f'{file_name}.crt') - signing_crt = read_crt(CERT_DIR / signing_crt_path) + else: + echo(style('Not signing certificate.', fg='red') + + ' Please check with this collaborator to get the' + ' correct certificate for this federation.') + return - echo('The CSR Hash for file ' - + style(f'{file_name}.csr', fg='green') - + ' = ' - + style(f'{csr_hash}', fg='red')) + if len(common_name) == 0: + # If the collaborator name is provided, the collaborator and + # certificate does not need to be exported + return - if silent: + # Remove unneeded CSR + remove(f'{cert_name}.csr') - echo(' Signing COLLABORATOR certificate') - signed_col_cert = sign_certificate(csr, signing_key, signing_crt.subject) - write_crt(signed_col_cert, f'{cert_name}.crt') - register_collaborator(CERT_DIR / 'client' / f'{file_name}.crt') + archive_type = 'zip' + archive_name = f'agg_to_{file_name}_signed_cert' - else: + # Collaborator certificate signing request + tmp_dir = join(mkdtemp(), 'openfl', archive_name) + + Path(tmp_dir).mkdir(parents=True, exist_ok=True) + # Copy the signed cert to the temporary directory + copy(f'{cert_path}/{file_name}.crt', f'{tmp_dir}/') + # Copy the CA certificate chain to the temporary directory + copy(f'{cert_path}/cert_chain.crt', tmp_dir) - if confirm('Do you want to sign this certificate?'): + # Create Zip archive of directory + make_archive(archive_name, archive_type, tmp_dir) + rmtree(tmp_dir) + + else: + # Copy the signed certificate and cert chain into PKI_DIR + previous_crts = glob(f'{cert_path}/*.crt') + unpack_archive(import_, extract_dir=cert_path) + updated_crts = glob(f'{cert_path}/*.crt') + cert_difference = list(set(updated_crts) - set(previous_crts)) + if len(cert_difference) != 0: + crt = basename(cert_difference[0]) + echo(f'Certificate {crt} installed to PKI directory') + else: + echo('Certificate updated in the PKI directory') + else: + if not import_: + if request_pkg: + Path(f'{CERT_DIR}/client').mkdir(parents=True, exist_ok=True) + unpack_archive(request_pkg, extract_dir=f'{CERT_DIR}/client') + csr = glob(f'{CERT_DIR}/client/*.csr')[0] + else: + if collaborator_name is None: + echo('collaborator_name can only be omitted if signing\n' + 'a zipped request package.\n' + '\n' + 'Example: fx collaborator certify --request-pkg ' + 'col_one_to_agg_cert_request.zip') + return + csr = glob(f'{CERT_DIR}/client/col_{common_name}.csr')[0] + copy(csr, CERT_DIR) + cert_name = splitext(csr)[0] + file_name = basename(cert_name) + signing_key_path = 'ca/signing-ca/private/signing-ca.key' + signing_crt_path = 'ca/signing-ca.crt' + + # Load CSR + if not Path(f'{cert_name}.csr').exists(): + echo(style('Collaborator certificate signing request not found.', fg='red') + + ' Please run `fx collaborator generate-cert-request`' + ' to generate the certificate request.') + + csr, csr_hash = read_csr(f'{cert_name}.csr') + + # Load private signing key + if not Path(CERT_DIR / signing_key_path).exists(): + echo(style('Signing key not found.', fg='red') + + ' Please run `fx workspace certify`' + ' to initialize the local certificate authority.') + + signing_key = read_key(CERT_DIR / signing_key_path) + + # Load signing cert + if not Path(CERT_DIR / signing_crt_path).exists(): + echo(style('Signing certificate not found.', fg='red') + + ' Please run `fx workspace certify`' + ' to initialize the local certificate authority.') + + signing_crt = read_crt(CERT_DIR / signing_crt_path) + + echo('The CSR Hash for file ' + + style(f'{file_name}.csr', fg='green') + + ' = ' + + style(f'{csr_hash}', fg='red')) + + if silent: echo(' Signing COLLABORATOR certificate') signed_col_cert = sign_certificate(csr, signing_key, signing_crt.subject) @@ -355,43 +501,73 @@ def certify(collaborator_name, silent, request_pkg=None, import_=False): register_collaborator(CERT_DIR / 'client' / f'{file_name}.crt') else: - echo(style('Not signing certificate.', fg='red') - + ' Please check with this collaborator to get the' - ' correct certificate for this federation.') - return - if len(common_name) == 0: - # If the collaborator name is provided, the collaborator and - # certificate does not need to be exported - return + if confirm('Do you want to sign this certificate?'): - # Remove unneeded CSR - remove(f'{cert_name}.csr') + echo(' Signing COLLABORATOR certificate') + signed_col_cert = sign_certificate(csr, signing_key, signing_crt.subject) + write_crt(signed_col_cert, f'{cert_name}.crt') + register_collaborator(CERT_DIR / 'client' / f'{file_name}.crt') - archive_type = 'zip' - archive_name = f'agg_to_{file_name}_signed_cert' + else: + echo(style('Not signing certificate.', fg='red') + + ' Please check with this collaborator to get the' + ' correct certificate for this federation.') + return - # Collaborator certificate signing request - tmp_dir = join(mkdtemp(), 'openfl', archive_name) + if len(common_name) == 0: + # If the collaborator name is provided, the collaborator and + # certificate does not need to be exported + return - Path(f'{tmp_dir}/client').mkdir(parents=True, exist_ok=True) - # Copy the signed cert to the temporary directory - copy(f'{CERT_DIR}/client/{file_name}.crt', f'{tmp_dir}/client/') - # Copy the CA certificate chain to the temporary directory - copy(f'{CERT_DIR}/cert_chain.crt', tmp_dir) + # Remove unneeded CSR + remove(f'{cert_name}.csr') - # Create Zip archive of directory - make_archive(archive_name, archive_type, tmp_dir) - rmtree(tmp_dir) + archive_type = 'zip' + archive_name = f'agg_to_{file_name}_signed_cert' + + # Collaborator certificate signing request + tmp_dir = join(mkdtemp(), 'openfl', archive_name) + + Path(f'{tmp_dir}/client').mkdir(parents=True, exist_ok=True) + # Copy the signed cert to the temporary directory + copy(f'{CERT_DIR}/client/{file_name}.crt', f'{tmp_dir}/client/') + # Copy the CA certificate chain to the temporary directory + copy(f'{CERT_DIR}/cert_chain.crt', tmp_dir) + + # Create Zip archive of directory + make_archive(archive_name, archive_type, tmp_dir) + rmtree(tmp_dir) - else: - # Copy the signed certificate and cert chain into PKI_DIR - previous_crts = glob(f'{CERT_DIR}/client/*.crt') - unpack_archive(import_, extract_dir=CERT_DIR) - updated_crts = glob(f'{CERT_DIR}/client/*.crt') - cert_difference = list(set(updated_crts) - set(previous_crts)) - if len(cert_difference) != 0: - crt = basename(cert_difference[0]) - echo(f'Certificate {crt} installed to PKI directory') else: - echo('Certificate updated in the PKI directory') + # Copy the signed certificate and cert chain into PKI_DIR + previous_crts = glob(f'{CERT_DIR}/client/*.crt') + unpack_archive(import_, extract_dir=CERT_DIR) + updated_crts = glob(f'{CERT_DIR}/client/*.crt') + cert_difference = list(set(updated_crts) - set(previous_crts)) + if len(cert_difference) != 0: + crt = basename(cert_difference[0]) + echo(f'Certificate {crt} installed to PKI directory') + else: + echo('Certificate updated in the PKI directory') + + +@collaborator.command(name='uninstall-cert') +@option('-c', '--cert_path', + help='The cert path where pki certs reside', required=True) +@option('-k', '--key_path', + help='The key path where key reside', required=True) +def _uninstall_cert(cert_path, key_path): + """Uninstall cert/key pair under a given directory.""" + uninstall_cert(cert_path, key_path) + + +def uninstall_cert(cert_path=None, key_path=None): + """Uninstall certs under a given directory.""" + from openfl.utilities.utils import rmtree + from pathlib import Path + + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + rmtree(cert_path, ignore_errors=True) + rmtree(key_path, ignore_errors=True) diff --git a/openfl/interface/workspace.py b/openfl/interface/workspace.py index bba960093c..409a3b62a6 100644 --- a/openfl/interface/workspace.py +++ b/openfl/interface/workspace.py @@ -214,12 +214,18 @@ def import_(archive): @workspace.command(name='certify') -def certify_(): +@option('-cdir', '--cert_dir', + help='The cert directory path where CA certs and keys will reside', required=False) +@option('-c', '--cert_path', + help='The cert path where CA signing cert will reside', required=False) +@option('-k', '--key_path', + help='The cert path where CA key path will reside', required=False) +def certify_(cert_dir, cert_path, key_path): """Create certificate authority for federation.""" - certify() + certify(cert_dir, cert_path, key_path) -def certify(): +def certify(cert_dir=None, cert_path=None, key_path=None): """Create certificate authority for federation.""" from cryptography.hazmat.primitives import serialization @@ -233,20 +239,27 @@ def certify(): echo('1. Create Root CA') echo('1.1 Create Directories') - (CERT_DIR / 'ca/root-ca/private').mkdir( + if cert_dir: + cert_dir = Path(cert_dir).absolute() + (cert_dir / 'cert').mkdir(parents=True, exist_ok=True) + cert_dir_path = cert_dir / 'cert' + else: + cert_dir_path = CERT_DIR + + (cert_dir_path / 'ca/root-ca/private').mkdir( parents=True, exist_ok=True, mode=0o700) - (CERT_DIR / 'ca/root-ca/db').mkdir(parents=True, exist_ok=True) + (cert_dir_path / 'ca/root-ca/db').mkdir(parents=True, exist_ok=True) echo('1.2 Create Database') - with open(CERT_DIR / 'ca/root-ca/db/root-ca.db', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/root-ca/db/root-ca.db', 'w', encoding='utf-8') as f: pass # write empty file - with open(CERT_DIR / 'ca/root-ca/db/root-ca.db.attr', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/root-ca/db/root-ca.db.attr', 'w', encoding='utf-8') as f: pass # write empty file - with open(CERT_DIR / 'ca/root-ca/db/root-ca.crt.srl', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/root-ca/db/root-ca.crt.srl', 'w', encoding='utf-8') as f: f.write('01') # write file with '01' - with open(CERT_DIR / 'ca/root-ca/db/root-ca.crl.srl', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/root-ca/db/root-ca.crl.srl', 'w', encoding='utf-8') as f: f.write('01') # write file with '01' echo('1.3 Create CA Request and Certificate') @@ -257,12 +270,12 @@ def certify(): root_private_key, root_cert = generate_root_cert() # Write root CA certificate to disk - with open(CERT_DIR / root_crt_path, 'wb') as f: + with open(cert_dir_path / root_crt_path, 'wb') as f: f.write(root_cert.public_bytes( encoding=serialization.Encoding.PEM, )) - with open(CERT_DIR / root_key_path, 'wb') as f: + with open(cert_dir_path / root_key_path, 'wb') as f: f.write(root_private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, @@ -272,60 +285,85 @@ def certify(): echo('2. Create Signing Certificate') echo('2.1 Create Directories') - (CERT_DIR / 'ca/signing-ca/private').mkdir( + (cert_dir_path / 'ca/signing-ca/private').mkdir( parents=True, exist_ok=True, mode=0o700) - (CERT_DIR / 'ca/signing-ca/db').mkdir(parents=True, exist_ok=True) + (cert_dir_path / 'ca/signing-ca/db').mkdir(parents=True, exist_ok=True) echo('2.2 Create Database') - with open(CERT_DIR / 'ca/signing-ca/db/signing-ca.db', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/signing-ca/db/signing-ca.db', 'w', encoding='utf-8') as f: pass # write empty file - with open(CERT_DIR / 'ca/signing-ca/db/signing-ca.db.attr', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/signing-ca/db/signing-ca.db.attr', 'w', encoding='utf-8') as f: pass # write empty file - with open(CERT_DIR / 'ca/signing-ca/db/signing-ca.crt.srl', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/signing-ca/db/signing-ca.crt.srl', 'w', encoding='utf-8') as f: f.write('01') # write file with '01' - with open(CERT_DIR / 'ca/signing-ca/db/signing-ca.crl.srl', 'w', encoding='utf-8') as f: + with open(cert_dir_path / 'ca/signing-ca/db/signing-ca.crl.srl', 'w', encoding='utf-8') as f: f.write('01') # write file with '01' echo('2.3 Create Signing Certificate CSR') - signing_csr_path = 'ca/signing-ca.csr' - signing_crt_path = 'ca/signing-ca.crt' - signing_key_path = 'ca/signing-ca/private/signing-ca.key' - signing_private_key, signing_csr = generate_signing_csr() # Write Signing CA CSR to disk - with open(CERT_DIR / signing_csr_path, 'wb') as f: + signing_csr_path = 'ca/signing-ca.csr' + with open(cert_dir_path / signing_csr_path, 'wb') as f: f.write(signing_csr.public_bytes( encoding=serialization.Encoding.PEM, )) - with open(CERT_DIR / signing_key_path, 'wb') as f: - f.write(signing_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - )) + if key_path: + key_path = Path(key_path).absolute() + signing_key_path = 'signing-ca.key' + with open(key_path / signing_key_path, 'wb') as f: + f.write(signing_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + )) + else: + signing_key_path = 'ca/signing-ca/private/signing-ca.key' + with open(cert_dir_path / signing_key_path, 'wb') as f: + f.write(signing_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + )) echo('2.4 Sign Signing Certificate CSR') signing_cert = sign_certificate(signing_csr, root_private_key, root_cert.subject, ca=True) - with open(CERT_DIR / signing_crt_path, 'wb') as f: - f.write(signing_cert.public_bytes( - encoding=serialization.Encoding.PEM, - )) + if cert_path: + cert_path = Path(cert_path).absolute() + signing_crt_path = 'signing-ca.crt' + with open(cert_path / signing_crt_path, 'wb') as f: + f.write(signing_cert.public_bytes( + encoding=serialization.Encoding.PEM, + )) + else: + signing_crt_path = 'ca/signing-ca.crt' + with open(cert_dir_path / signing_crt_path, 'wb') as f: + f.write(signing_cert.public_bytes( + encoding=serialization.Encoding.PEM, + )) echo('3 Create Certificate Chain') # create certificate chain file by combining root-ca and signing-ca - with open(CERT_DIR / 'cert_chain.crt', 'w', encoding='utf-8') as d: - with open(CERT_DIR / 'ca/root-ca.crt', encoding='utf-8') as s: - d.write(s.read()) - with open(CERT_DIR / 'ca/signing-ca.crt') as s: - d.write(s.read()) + if cert_path: + cert_chain_path = Path(cert_path).absolute() + with open(cert_chain_path / 'cert_chain.crt', 'w', encoding='utf-8') as d: + with open(cert_dir_path / 'ca/root-ca.crt', encoding='utf-8') as s: + d.write(s.read()) + with open(cert_chain_path / 'signing-ca.crt') as s: + d.write(s.read()) + else: + with open(cert_dir_path / 'cert_chain.crt', 'w', encoding='utf-8') as d: + with open(cert_dir_path / 'ca/root-ca.crt', encoding='utf-8') as s: + d.write(s.read()) + with open(cert_dir_path / 'ca/signing-ca.crt') as s: + d.write(s.read()) echo('\nDone.') @@ -536,6 +574,79 @@ def open_pipe(command: str): echo(f'\n ✔️ The image saved to file: {tag}.tar.gz') +@workspace.command(name='participants') +@option('-c', '--cert_path', + help='Cert path where signing CA certs reside', required=False) +@option('--no_cert', help='Add uncertified participants to plan', is_flag=True) +def participants_(cert_path=None, no_cert=False): + """Select a subset of registered collaborators for the experiment.""" + participants(cert_path, no_cert) + + +def participants(cert_path=None, no_cert=False): + """Select a subset of collaborators from all regsitered collaborators.""" + from click import Choice + from click import confirm + from click import edit + from click import prompt as click_prompt + from glob import glob + from yaml import dump + from pathlib import Path + from openfl.interface.cli_helper import CERT_DIR + + if cert_path: + cert_path = Path(cert_path).absolute() + participant_certs = glob(f'{cert_path}/col_*.crt') + else: + participant_certs = glob(f'{CERT_DIR}/client/col_*.crt') + + if participant_certs: + total_participants = [] + for cert in participant_certs: + total_participants.append(os.path.basename(os.path.normpath(cert)).split('.')[0][4:]) + + collaborators_added = [] + collaborators_to_add = True + + while collaborators_to_add: + collaborator = click_prompt('Select collaborator for the experiment: ', + type=Choice(total_participants), show_choices=True) + collaborators_added.append(collaborator) + total_participants.remove(collaborator) + + if not confirm('Are there additional collaborators to add to the experiment?'): + collaborators_to_add = False + + cols_file = Path('plan/cols.yaml').absolute() + with open(cols_file, 'w', encoding='utf-8') as f: + dump({'collaborators': collaborators_added}, f) + + if no_cert: + if confirm('Do you want to add participants (- participant_name) to the experiment?'): + edit(filename='plan/cols.yaml') + + +@workspace.command(name='uninstall-cert') +@option('-c', '--cert_path', + help='The cert path where pki certs reside', required=True) +@option('-k', '--key_path', + help='The key path where key reside', required=True) +def _uninstall_cert(cert_path, key_path): + """Uninstall cert/key pair under a given directory.""" + uninstall_cert(cert_path, key_path) + + +def uninstall_cert(cert_path=None, key_path=None): + """Uninstall certs under a given directory.""" + from openfl.utilities.utils import rmtree + from pathlib import Path + + cert_path = Path(cert_path).absolute() + key_path = Path(key_path).absolute() + rmtree(cert_path, ignore_errors=True) + rmtree(key_path, ignore_errors=True) + + def apply_template_plan(prefix, template): """Copy plan file from template folder. diff --git a/tests/github/test_pki_cert_location.py b/tests/github/test_pki_cert_location.py new file mode 100644 index 0000000000..fd6ae87559 --- /dev/null +++ b/tests/github/test_pki_cert_location.py @@ -0,0 +1,118 @@ +# Copyright (C) 2020-2021 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import os +import time +import socket +import argparse +from pathlib import Path +from subprocess import check_call +from concurrent.futures import ProcessPoolExecutor + +from openfl.utilities.utils import rmtree +from tests.github.utils import create_collaborator, create_certified_workspace, certify_aggregator + + +if __name__ == '__main__': + # Test the pipeline + parser = argparse.ArgumentParser() + workspace_choice = [] + with os.scandir('openfl-workspace') as iterator: + for entry in iterator: + if entry.name not in ['__init__.py', 'workspace', 'default']: + workspace_choice.append(entry.name) + parser.add_argument('--template', default='keras_cnn_mnist', choices=workspace_choice) + parser.add_argument('--fed_workspace', default='fed_work12345alpha81671') + parser.add_argument('--col1', default='one123dragons') + parser.add_argument('--col2', default='beta34unicorns') + parser.add_argument('--rounds-to-train') + parser.add_argument('--col1-data-path', default='1') + parser.add_argument('--col2-data-path', default='2') + parser.add_argument('--agg-cert-path', default=Path.cwd()) + parser.add_argument('--agg-key-path', default=Path.cwd()) + parser.add_argument('--col1-cert-path', default=Path.cwd()) + parser.add_argument('--col1-key-path', default=Path.cwd()) + parser.add_argument('--col2-cert-path', default=Path.cwd()) + parser.add_argument('--col2-key-path', default=Path.cwd()) + parser.add_argument('--save-model') + + origin_dir = Path.cwd().resolve() + args = parser.parse_args() + fed_workspace = args.fed_workspace + archive_name = f'{fed_workspace}.zip' + fqdn = socket.getfqdn() + template = args.template + rounds_to_train = args.rounds_to_train + col1, col2 = args.col1, args.col2 + col1_data_path, col2_data_path = args.col1_data_path, args.col2_data_path + + os.makedirs(args.agg_cert_path, exist_ok=True) + os.makedirs(args.agg_key_path, exist_ok=True) + os.makedirs(args.col1_cert_path, exist_ok=True) + os.makedirs(args.col1_key_path, exist_ok=True) + os.makedirs(args.col2_cert_path, exist_ok=True) + os.makedirs(args.col2_key_path, exist_ok=True) + + ca_dir_path = Path(args.agg_cert_path).resolve() + agg_cert_path = Path(args.agg_cert_path).resolve() + agg_key_path = Path(args.agg_key_path).resolve() + col1_cert_path = Path(args.col1_cert_path).resolve() + col1_key_path = Path(args.col1_key_path).resolve() + col2_cert_path = Path(args.col2_cert_path).resolve() + col2_key_path = Path(args.col2_key_path).resolve() + save_model = args.save_model + + # START + # ===== + # Make sure you are in a Python virtual environment with the FL package installed. + create_certified_workspace(fed_workspace, template, fqdn, + rounds_to_train, ca_dir_path, agg_cert_path, agg_key_path) + certify_aggregator(fqdn, agg_cert_path, agg_key_path) + + workspace_root = Path().resolve() # Get the absolute directory path for the workspace + + # Create collaborator #1 + create_collaborator(col1, workspace_root, col1_data_path, archive_name, + fed_workspace, col1_cert_path, col1_key_path, agg_cert_path, agg_key_path) + + # Create collaborator #2 + create_collaborator(col2, workspace_root, col2_data_path, archive_name, + fed_workspace, col2_cert_path, col2_key_path, agg_cert_path, agg_key_path) + + # Run the federation + with ProcessPoolExecutor(max_workers=3) as executor: + executor.submit( + check_call, ['fx', 'aggregator', 'start', '-c', agg_cert_path, '-k', agg_key_path], + cwd=workspace_root) + time.sleep(5) + + dir1 = workspace_root / col1 / fed_workspace + executor.submit( + check_call, ['fx', 'collaborator', 'start', '-n', col1, + '-c', col1_cert_path, '-k', col1_key_path], + cwd=dir1) + + dir2 = workspace_root / col2 / fed_workspace + executor.submit( + check_call, ['fx', 'collaborator', 'start', '-n', col2, + '-c', col2_cert_path, '-k', col2_key_path], + cwd=dir2) + + # Convert model to native format + if save_model: + check_call( + ['fx', 'model', 'save', '-i', f'./save/{template}_last.pbuf', '-o', save_model], + cwd=workspace_root) + + # Clear cert paths + check_call( + ['fx', 'aggregator', 'uninstall-cert', '-c', agg_cert_path, '-k', agg_key_path], + cwd=workspace_root) + check_call( + ['fx', 'collaborator', 'uninstall-cert', '-c', col1_cert_path, '-k', col1_key_path], + cwd=workspace_root / col1 / fed_workspace) + check_call( + ['fx', 'collaborator', 'uninstall-cert', '-c', col2_cert_path, '-k', col2_key_path], + cwd=workspace_root / col2 / fed_workspace) + os.chdir(origin_dir) + rmtree(workspace_root) diff --git a/tests/github/utils.py b/tests/github/utils.py index 96f283accd..4f1df92819 100644 --- a/tests/github/utils.py +++ b/tests/github/utils.py @@ -8,7 +8,8 @@ import tarfile -def create_collaborator(col, workspace_root, data_path, archive_name, fed_workspace): +def create_collaborator(col, workspace_root, data_path, archive_name, fed_workspace, + cert_path=None, key_path=None, ca_cert_path=None, ca_key_path=None): # Copy workspace to collaborator directories (these can be on different machines) col_path = workspace_root / col shutil.rmtree(col_path, ignore_errors=True) # Remove any existing directory @@ -22,27 +23,49 @@ def create_collaborator(col, workspace_root, data_path, archive_name, fed_worksp # Create collaborator certificate request # Remove '--silent' if you run this manually - check_call( - ['fx', 'collaborator', 'generate-cert-request', '-d', data_path, '-n', col, '--silent'], - cwd=col_path / fed_workspace - ) + if cert_path and key_path: + check_call( + ['fx', 'collaborator', 'generate-cert-request', '-d', data_path, + '-n', col, '-c', cert_path, '-k', key_path, '--silent'], + cwd=col_path / fed_workspace + ) + else: + check_call( + ['fx', 'collaborator', 'generate-cert-request', '-d', data_path, + '-n', col, '--silent'], + cwd=col_path / fed_workspace + ) # Sign collaborator certificate # Remove '--silent' if you run this manually request_pkg = col_path / fed_workspace / f'col_{col}_to_agg_cert_request.zip' - check_call( - ['fx', 'collaborator', 'certify', '--request-pkg', str(request_pkg), '--silent'], - cwd=workspace_root) + if ca_cert_path and ca_key_path: + check_call( + ['fx', 'collaborator', 'certify', '--request-pkg', str(request_pkg), + '-n', col, '-c', ca_cert_path, '-k', ca_key_path, '--silent'], + cwd=workspace_root) + else: + check_call( + ['fx', 'collaborator', 'certify', '--request-pkg', str(request_pkg), '--silent'], + cwd=workspace_root) # Import the signed certificate from the aggregator import_path = workspace_root / f'agg_to_col_{col}_signed_cert.zip' - check_call( - ['fx', 'collaborator', 'certify', '--import', import_path], - cwd=col_path / fed_workspace - ) - - -def create_certified_workspace(path, template, fqdn, rounds_to_train): + if cert_path and key_path: + check_call( + ['fx', 'collaborator', 'certify', '--import', import_path, + '-c', cert_path, '-k', key_path], + cwd=col_path / fed_workspace + ) + else: + check_call( + ['fx', 'collaborator', 'certify', '--import', import_path], + cwd=col_path / fed_workspace + ) + + +def create_certified_workspace(path, template, fqdn, rounds_to_train, + cert_dir=None, cert_path=None, key_path=None): shutil.rmtree(path, ignore_errors=True) check_call(['fx', 'workspace', 'create', '--prefix', path, '--template', template]) os.chdir(path) @@ -62,18 +85,29 @@ def create_certified_workspace(path, template, fqdn, rounds_to_train): except (ValueError, TypeError): pass # Create certificate authority for workspace - check_call(['fx', 'workspace', 'certify']) + if cert_dir and cert_path and key_path: + check_call(['fx', 'workspace', 'certify', + '-cdir', cert_dir, '-c', cert_path, '-k', key_path]) + else: + check_call(['fx', 'workspace', 'certify']) # Export FL workspace check_call(['fx', 'workspace', 'export']) -def certify_aggregator(fqdn): - # Create aggregator certificate - check_call(['fx', 'aggregator', 'generate-cert-request', '--fqdn', fqdn]) - - # Sign aggregator certificate - check_call(['fx', 'aggregator', 'certify', '--fqdn', fqdn, '--silent']) +def certify_aggregator(fqdn, cert_path=None, key_path=None): + if cert_path and key_path: + # Create aggregator certificate + check_call(['fx', 'aggregator', 'generate-cert-request', '--fqdn', fqdn, + '-c', cert_path, '-k', key_path]) + # Sign aggregator certificate + check_call(['fx', 'aggregator', 'certify', '--fqdn', fqdn, + '-c', cert_path, '-k', key_path, '--silent']) + else: + # Create aggregator certificate + check_call(['fx', 'aggregator', 'generate-cert-request', '--fqdn', fqdn]) + # Sign aggregator certificate + check_call(['fx', 'aggregator', 'certify', '--fqdn', fqdn, '--silent']) def create_signed_cert_for_collaborator(col, data_path): @@ -81,6 +115,7 @@ def create_signed_cert_for_collaborator(col, data_path): We do certs exchage for all participants in a single workspace to speed up this test run. Do not do this in real experiments in untrusted environments ''' + from openfl.interface.cli_helper import CERT_DIR print(f'Certifying collaborator {col} with data path {data_path}...') # Create collaborator certificate request check_call([ @@ -99,7 +134,7 @@ def create_signed_cert_for_collaborator(col, data_path): # Pack the collaborators private key and the signed cert # as well as it's data.yaml to a tarball tarfiles = ['plan/data.yaml', f'agg_to_col_{col}_signed_cert.zip'] - with os.scandir('cert/client') as iterator: + with os.scandir(f'{CERT_DIR}/client') as iterator: for entry in iterator: if entry.name.endswith('key'): tarfiles.append(entry.path)