From 6c643aeadccca75bf73f48fe391d3deb89fe641d Mon Sep 17 00:00:00 2001 From: Arne Welzel Date: Mon, 30 Mar 2026 15:06:04 +0200 Subject: [PATCH] cluster_backend_zeromq: Automatically enable encryption for multi-node clusters This change introduces two new options for the ZeroMQ cluster backend: cluster_backend_zeromq.use_curve_encryption = auto|0|1 cluster_backend_zeromq.curve_dir = ${SpoolDir}/zeromq/curve The directory is used to persistently store the generated keys. Keys are generated by invoking Zeek and calling the ZeroMQ specific BiF generate_keypair(). To pre-provision keys, it's possible to populate the directory before invoking zeekctl. By default, when a multi-node cluster is detected, encryption and key generation is implicitly enabled. Setting use_curve_encryption to "0" or "1" allows for explicit configuration. The default is "auto". The keys are rendered verbatim into zeekctl-config.zeek. The assumption here is that if anyone manages to get access to the zeekctl-config.zeek file, they'll likely have a way to get to the keys in a different way already. --- ZeekControl/plugins/cluster_backend_zeromq.py | 145 +++++++++++++++++- .../found-keys | 5 + .../zeekctl-config-redefs | 26 ++++ ...all-cluster-backend-zeromq-encryption.test | 26 ++++ 4 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 testing/Baseline/command.install-cluster-backend-zeromq-encryption/found-keys create mode 100644 testing/Baseline/command.install-cluster-backend-zeromq-encryption/zeekctl-config-redefs create mode 100644 testing/command/install-cluster-backend-zeromq-encryption.test diff --git a/ZeekControl/plugins/cluster_backend_zeromq.py b/ZeekControl/plugins/cluster_backend_zeromq.py index b9054c30..87c77de4 100644 --- a/ZeekControl/plugins/cluster_backend_zeromq.py +++ b/ZeekControl/plugins/cluster_backend_zeromq.py @@ -1,5 +1,11 @@ +import json +import os.path +import subprocess +from pathlib import Path + import ZeekControl.config import ZeekControl.plugin +from ZeekControl.exceptions import ConfigurationError class ClusterBackendZeroMQ(ZeekControl.plugin.Plugin): @@ -19,8 +25,52 @@ def options(self): "bool", False, "Disable the multi-node unencrypted warning.", - ) + ), + ( + "use_curve_encryption", + "string", + "auto", + "Whether to enable ZeroMQ CURVE-based encryption for cluster communication (auto, 0 or 1)", + ), + ( + "curve_dir", + "string", + "${SpoolDir}/zeromq/curve", + "Directory to persistently store client and server curve keys on the manager", + ), + ] + + def generate_keypair(self, publickey: Path, secretkey: Path): + """ + Generate the ZeroMQ CURVE keypair in Z85 encoded format and write + them to the files pointed at by publickey and secretkey. The keys + themselves are generated using zeek and calling the appropriate BiF. + """ + zeek = self.getGlobalOption("zeek") + if not os.path.lexists(zeek): + raise ConfigurationError(f"cannot find Zeek binary: {zeek}") + + args = [ + zeek, + "-b", + "-e", + "print to_json(Cluster::Backend::ZeroMQ::generate_keypair())", ] + output = subprocess.check_output(args) + loaded = json.loads(output) + public, secret = loaded["public"], loaded["secret"] + if len(public) != 40 or len(secret) != 40: + raise ConfigurationError( + f"failed to create ZeroMQ CURVE keypair {loaded!r}" + ) + + publickey.touch(exist_ok=True) + publickey.chmod(0o600) + publickey.write_text(public + "\n") + + secretkey.touch(exist_ok=True) + secretkey.chmod(0o600) + secretkey.write_text(secret + "\n") def init(self): """ @@ -55,21 +105,66 @@ def init(self): # Check if this is a multi-node cluster (multiple IP addresses) and # tell the user about it. addrs = {n.addr for n in self.nodes()} - if len(addrs) > 1 and not self.getOption("disable_unencrypted_warning"): + + # If use_curve_encryption is "auto", determine 0 or 1 based on + # the number of different node addresses available. If it is + # already "0" or "1", just make it the integer value. + self.use_curve_encryption = ( + self.getOption("use_curve_encryption").lower().strip() + ) + if len(addrs) > 1 and self.use_curve_encryption == "auto": + self.use_curve_encryption = 1 + elif len(addrs) == 1 and self.use_curve_encryption == "auto": + self.use_curve_encryption = 0 + elif self.use_curve_encryption in ["0", "1"]: + self.use_curve_encryption = int(self.use_curve_encryption) + elif self.use_curve_encryption in ["true", "false"]: + self.use_curve_encryption = 1 if self.use_curve_encryption == "true" else 0 + else: + raise ConfigurationError( + f"invalid UseCurveEncryption value: {self.use_curve_encryption}" + ) + + # Store public and secret keys in in spool/zeromq/curve by default. + curve_dir = self.getOption("curve_dir") + self.server_publickey = Path(curve_dir) / "server_publickey" + self.server_secretkey = Path(curve_dir) / "server_secretkey" + self.client_publickey = Path(curve_dir) / "client_publickey" + self.client_secretkey = Path(curve_dir) / "client_secretkey" + + # If encryption is enabled, create the spool directory for + # the server and client keypairs and generate them if needed. + if self.use_curve_encryption: + os.makedirs(curve_dir, exist_ok=True) + os.chmod(curve_dir, 0o700) + + if not self.server_publickey.exists() or not self.server_secretkey.exists(): + self.message("Generating ZeroMQ CURVE server keypair...") + self.generate_keypair(self.server_publickey, self.server_secretkey) + + if not self.client_publickey.exists() or not self.client_secretkey.exists(): + self.message("Generating ZeroMQ CURVE client keypair...") + self.generate_keypair(self.client_publickey, self.client_secretkey) + + if ( + not self.use_curve_encryption + and len(addrs) > 1 + and not self.getOption("disable_unencrypted_warning") + ): self.message( - f'Warning: ZeroMQ cluster backend enabled and multi-node cluster detected (IPs {", ".join(addrs)}).' + f'Warning: ZeroMQ encryption disabled, but multi-node cluster detected (IPs {", ".join(addrs)}).' ) self.message( - "Communication between Zeek nodes using ZeroMQ is currently unencrypted. Use Broker with TLS if this" + "\nYou may disable this warning by setting the following option in zeekctl.cfg:" ) self.message( - "is concerning to you. ZeroMQ encryption is tracked at https://github.com/zeek/zeek/issues/4432" + "\n cluster_backend_zeromq.disable_unencrypted_warning = 1\n" ) self.message( - "\nYou may disable this warning by setting the following option in zeekctl.cfg:" + "\nYou may enable encryption by setting the following option.cfg:" ) self.message( - "\n cluster_backend_zeromq.disable_unencrypted_warning = 1\n" + "\n cluster_backend_zeromq.use_curve_encryption = 1 (or auto)\n" ) # If any of the addresses used by nodes looks like an IPv6 address, @@ -103,6 +198,42 @@ def zeekctl_config(self): ] ) + # If CURVE encryption is enabled, redef the server and client + # keys into the zeekctl-config file. + if self.use_curve_encryption: + + def render_redef(path: Path) -> str: + """ + Small helper to render a redef line given the path to + the keypair files stored in curve_dir. + """ + what = path.parts[-1] + value = path.read_text().strip() + if len(value) != 40: + raise ConfigurationError(f"CURVE key {what} not 40 bytes long") + + return "\n".join( + [ + f"@if ( |Cluster::Backend::ZeroMQ::curve_{what}| == 0 )", + f'redef Cluster::Backend::ZeroMQ::curve_{what} = "{value}";', + "@endif", + ] + ) + + script += "\n".join( + [ + "", + "# Public and secret server keys for ZeroMQ CURVE encryption.", + render_redef(self.server_publickey), + render_redef(self.server_secretkey), + "", + "# Public and secret client keys for ZeroMQ CURVE encryption.", + render_redef(self.client_publickey), + render_redef(self.client_secretkey), + "", + ] + ) + # Usually this runs automatically on the manager, but Zeectl supports # standalone mode and the node doesn't know it should run the proxy # thread for WebSocket functionality. diff --git a/testing/Baseline/command.install-cluster-backend-zeromq-encryption/found-keys b/testing/Baseline/command.install-cluster-backend-zeromq-encryption/found-keys new file mode 100644 index 00000000..2fecb3ca --- /dev/null +++ b/testing/Baseline/command.install-cluster-backend-zeromq-encryption/found-keys @@ -0,0 +1,5 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +redef Cluster::Backend::ZeroMQ::curve_client_publickey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +redef Cluster::Backend::ZeroMQ::curve_client_secretkey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +redef Cluster::Backend::ZeroMQ::curve_server_publickey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +redef Cluster::Backend::ZeroMQ::curve_server_secretkey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" diff --git a/testing/Baseline/command.install-cluster-backend-zeromq-encryption/zeekctl-config-redefs b/testing/Baseline/command.install-cluster-backend-zeromq-encryption/zeekctl-config-redefs new file mode 100644 index 00000000..d64a74aa --- /dev/null +++ b/testing/Baseline/command.install-cluster-backend-zeromq-encryption/zeekctl-config-redefs @@ -0,0 +1,26 @@ +### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63. +@endif + +redef Cluster::Backend::ZeroMQ::listen_xpub_endpoint = "tcp://127.0.0.1:27760"; +redef Cluster::Backend::ZeroMQ::listen_xsub_endpoint = "tcp://127.0.0.1:27761"; +redef Cluster::Backend::ZeroMQ::connect_xpub_endpoint = "tcp://127.0.0.1:27761"; +redef Cluster::Backend::ZeroMQ::connect_xsub_endpoint = "tcp://127.0.0.1:27760"; + +redef Cluster::Backend::ZeroMQ::ipv6 = F; + +# Public and secret server keys for ZeroMQ CURVE encryption. +@if ( |Cluster::Backend::ZeroMQ::curve_server_publickey| == 0 ) +redef Cluster::Backend::ZeroMQ::curve_server_publickey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +@endif +@if ( |Cluster::Backend::ZeroMQ::curve_server_secretkey| == 0 ) +redef Cluster::Backend::ZeroMQ::curve_server_secretkey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +@endif + +# Public and secret client keys for ZeroMQ CURVE encryption. +@if ( |Cluster::Backend::ZeroMQ::curve_client_publickey| == 0 ) +redef Cluster::Backend::ZeroMQ::curve_client_publickey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +@endif +@if ( |Cluster::Backend::ZeroMQ::curve_client_secretkey| == 0 ) +redef Cluster::Backend::ZeroMQ::curve_client_secretkey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +@endif + diff --git a/testing/command/install-cluster-backend-zeromq-encryption.test b/testing/command/install-cluster-backend-zeromq-encryption.test new file mode 100644 index 00000000..51024074 --- /dev/null +++ b/testing/command/install-cluster-backend-zeromq-encryption.test @@ -0,0 +1,26 @@ +# @TEST-DOC: Test enabling cluster_backend_zeromq.use_curve_encryption and observe the generated keys and redefs in the configuration file. +# +# @TEST-EXEC: PATH=$(pwd)/bin:$PATH bash %INPUT +# +# @TEST-EXEC: TEST_DIFF_CANONIFIER="sed -E -e 's/\".{40}\";/\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"/g'" btest-diff found-keys +# @TEST-EXEC: TEST_DIFF_CANONIFIER="sed -E -e 's/\".{40}\";/\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"/g'" btest-diff zeekctl-config-redefs + +. zeekctl-test-setup + +config=$ZEEKCTL_INSTALL_PREFIX/spool/installed-scripts-do-not-touch/auto/zeekctl-config.zeek + +installfile etc/node.cfg__cluster +# Enable CURVE encryption +echo "cluster_backend_zeromq.use_curve_encryption = 1" >> $ZEEKCTL_INSTALL_PREFIX/etc/zeekctl.cfg +zeekctl install + +# Check if the keys generated into spool/zeromq/curve appear in the +# generated configuration file. +for f in $ZEEKCTL_INSTALL_PREFIX/spool/zeromq/curve/* ; do + if ! grep -F -f $f $config >> found-keys; then + echo "Could not find key $f in $config" >&2 + exit 1 + fi +done + +grep -C2 'redef.*Cluster::Backend' $config > zeekctl-config-redefs