diff --git a/examples/extensions/README.md b/examples/extensions/README.md index 42c7fe29..42e55859 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -1,9 +1,13 @@ # Conan extensions examples -### [Use custom commands in your Conan CLI](extensions/commands/) +### [Use custom commands in your Conan CLI](commands) - Learn how to create custom commands in Conan. [Docs](https://docs.conan.io/2/reference/commands/custom_commands.html) -### [Use custom deployers](extensions/deployers/) +### [Use custom deployers](deployers) - Learn how to create a custom deployer in Conan. [Docs](https://docs.conan.io/2/reference/extensions/deployers.html) + +### [Package signing plugin example with OpenSSL](plugins/openssl_sign) + +- Learn how to create a package signing plugin in Conan. [Docs](https://docs.conan.io/2/reference/extensions/package_signing.html) diff --git a/examples/extensions/plugins/openssl_sign/README.md b/examples/extensions/plugins/openssl_sign/README.md new file mode 100644 index 00000000..dc7b0ecb --- /dev/null +++ b/examples/extensions/plugins/openssl_sign/README.md @@ -0,0 +1,17 @@ + +## Package signing plugin example with OpenSSL + +> **_SECURITY NOTE:_** This example stores a private key next to the plugin for simplicity. **Do not do this in production**. +> Instead, load the signing key from environment variables or a secret manager, or delegate signing to a remote signing service. +> **Always keep the private key out of the Conan cache and out of source control**. + + +Steps to test the example: + +- Copy the ``sign.py`` file to your Conan home at ```CONAN_HOME/extensions/plugins/sign/sign.py```. +- Generate your signing keys (see comment at the top of the ``sign.py`` file) and place them inside a folder with the name of your provider (``my-organization`` in the example) next to the ``sign.py`` file (``CONAN_HOME/extensions/plugins/sign/my-organization/``). +- Generate a new project to test the sign and verify commands: ``conan new cmake_lib -d name=hello -d version=1.0`` +- Create the package: ``conan create`` +- Sign the package: ``conan cache sign hello/1.0`` +- Verify the package signature: ```conan cache verify hello/1.0``` +- You can also use the ``conan install`` command, and the packages should be verified automatically when they are downloaded from a remote. diff --git a/examples/extensions/plugins/openssl_sign/ci_test_example.py b/examples/extensions/plugins/openssl_sign/ci_test_example.py new file mode 100644 index 00000000..f9f308ad --- /dev/null +++ b/examples/extensions/plugins/openssl_sign/ci_test_example.py @@ -0,0 +1,28 @@ +import os +import shutil + +from conan import conan_version +from test.examples_tools import run + +if conan_version >= "2.26.0-dev": + current_dir = os.path.abspath(os.path.dirname(__file__)) + provider_folder = os.path.join(current_dir, "my-organization") + + os.makedirs(provider_folder) + run(f"openssl genpkey -algorithm RSA -out {provider_folder}/private_key.pem -pkeyopt rsa_keygen_bits:2048") + run(f"openssl pkey -in {provider_folder}/private_key.pem -pubout -out {provider_folder}/public_key.pem") + + run(f"conan config install {current_dir} -t dir --target-folder extensions/plugins/sign") + + run("conan new cmake_lib -d name=hello -d version=1.0") + run("conan create") + + output = run("conan cache sign hello/1.0") + assert "Package signed for reference hello/1.0" in output + assert "[Package sign] Summary: OK=2, FAILED=0" in output + output = run("conan cache verify hello/1.0") + assert "Package verified for reference hello/1.0" in output + assert "[Package sign] Summary: OK=2, FAILED=0" in output + + conan_home = run("conan config home").strip() + shutil.rmtree(os.path.join(conan_home, "extensions", "plugins", "sign")) diff --git a/examples/extensions/plugins/openssl_sign/sign.py b/examples/extensions/plugins/openssl_sign/sign.py new file mode 100644 index 00000000..0f090483 --- /dev/null +++ b/examples/extensions/plugins/openssl_sign/sign.py @@ -0,0 +1,117 @@ +""" +Plugin to sign/verify Conan packages with OpenSSL. + +You will need to have ``openssl`` installed at the system level and available in your ``PATH``. + +To use this plugin, first generate a compatible keypair: + + $ openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 + +And extract the public key: + + $ openssl pkey -in private_key.pem -pubout -out public_key.pem + +The private_key.pem and public_key.pem files should be placed inside a folder named with the the provider's name +('my-organization' for this example). The 'my-organization' folder should be next to this plugins' file sign.py +(inside the CONAN_HOME/extensions/plugins/sign folder). + +SECURITY NOTE: + This example stores a private key next to the plugin for simplicity. **Do not do this in production**. + Instead, load the signing key from environment variables or a secret manager, or delegate signing to a remote signing service. + **Always keep the private key out of the Conan cache and out of source control**. +""" + +import os +import json +import subprocess + +from conan.api.output import ConanOutput +from conan.errors import ConanException + + +def _run_command(command): + ConanOutput().info(f"Running command: {' '.join(command)}") + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, # returns strings instead of bytes + check=False # we'll manually handle error checking + ) + + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, result.args, output=result.stdout, stderr=result.stderr + ) + + +def sign(ref, artifacts_folder, signature_folder, **kwargs): + provider = "my-organization" # This maps to the folder containing the signing keys (for simplicity) + manifest_filepath = os.path.join(signature_folder, "pkgsign-manifest.json") + signature_filename = "pkgsign-manifest.json.sig" + signature_filepath = os.path.join(signature_folder, signature_filename) + if os.path.isfile(signature_filepath): + ConanOutput().warning(f"Package {ref.repr_notime()} was already signed") + + privkey_filepath = os.path.join(os.path.dirname(__file__), provider, "private_key.pem") + # openssl dgst -sha256 -sign private_key.pem -out document.sig document.txt + openssl_sign_cmd = [ + "openssl", + "dgst", + "-sha256", + "-sign", privkey_filepath, + "-out", signature_filepath, + manifest_filepath + ] + try: + _run_command(openssl_sign_cmd) + ConanOutput().success(f"Package signed for reference {ref}") + except Exception as exc: + raise ConanException(f"Error signing artifact: {exc}") + return [{"method": "openssl-dgst", + "provider": provider, + "sign_artifacts": { + "manifest": "pkgsign-manifest.json", + "signature": signature_filename}}] + + +def verify(ref, artifacts_folder, signature_folder, files, **kwargs): + signatures_path = os.path.join(signature_folder, "pkgsign-signatures.json") + try: + with open(signatures_path, "r", encoding="utf-8") as f: + signatures = json.loads(f.read()).get("signatures") + except Exception: + ConanOutput().warning("Could not verify unsigned package") + return + + for signature in signatures: + signature_filename = signature.get("sign_artifacts").get("signature") + signature_filepath = os.path.join(signature_folder, signature_filename) + if not os.path.isfile(signature_filepath): + raise ConanException(f"Signature file does not exist at {signature_filepath}") + + # The provider is useful to choose the correct public key to verify packages with + provider = signature.get("provider") + pubkey_filepath = os.path.join(os.path.dirname(__file__), provider, "public_key.pem") + if not os.path.isfile(pubkey_filepath): + raise ConanException(f"Public key not found for provider '{provider}'") + + manifest_filepath = os.path.join(signature_folder, "pkgsign-manifest.json") + signature_method = signature.get("method") + if signature_method == "openssl-dgst": + # openssl dgst -sha256 -verify public_key.pem -signature document.sig document.txt + openssl_verify_cmd = [ + "openssl", + "dgst", + "-sha256", + "-verify", pubkey_filepath, + "-signature", signature_filepath, + manifest_filepath, + ] + try: + _run_command(openssl_verify_cmd) + ConanOutput().success(f"Package verified for reference {ref}") + except Exception as exc: + raise ConanException(f"Error verifying signature {signature_filepath}: {exc}") + else: + raise ConanException(f"Sign method {signature_method} not supported. Cannot verify package")