From 8b3cef554b661b32ed182c16d98d020701640546 Mon Sep 17 00:00:00 2001 From: Prakhar Pratyush Date: Mon, 9 Jun 2025 23:29:58 +0530 Subject: [PATCH] settings: Add `push_registration_encryption_keys` map. The `push_registration_encryption_keys` map stores the assymetric key pair generated on bouncer. The public key will be used by the client to encrypt registration data and the bouncer will use the corresponding private key to decrypt. - Updated the `generate_secrets.py` script to generate the map during installation in dev environment. - Added a management command to add / remove key i.e. use it for key rotation while retaining the older key-pair for a period of time. --- pyproject.toml | 3 + scripts/setup/generate_secrets.py | 15 ++++ uv.lock | 24 +++++ version.py | 2 +- ...anage_push_registration_encryption_keys.py | 87 +++++++++++++++++++ zproject/computed_settings.py | 6 ++ 6 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 zilencer/management/commands/manage_push_registration_encryption_keys.py diff --git a/pyproject.toml b/pyproject.toml index 78cc35989e..9b0bf87eff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,6 +201,9 @@ prod = [ # For using Missing sentinel "pydantic-partials", + + # For E2EE of push notifications + "pynacl", ] docs = [ # Needed to build RTD docs diff --git a/scripts/setup/generate_secrets.py b/scripts/setup/generate_secrets.py index e5513a2902..b3d6086fde 100755 --- a/scripts/setup/generate_secrets.py +++ b/scripts/setup/generate_secrets.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # This tools generates /etc/zulip/zulip-secrets.conf +import json import os import sys from contextlib import suppress @@ -18,6 +19,9 @@ import argparse import configparser import uuid +from nacl.encoding import Base64Encoder +from nacl.public import PrivateKey + os.chdir(os.path.join(os.path.dirname(__file__), "..", "..")) # Standard, 64-bit tokens @@ -180,6 +184,17 @@ def generate_secrets(development: bool = False) -> None: if need_secret("zulip_org_id"): add_secret("zulip_org_id", str(uuid.uuid4())) + if development and need_secret("push_registration_encryption_keys"): + # 'settings.ZILENCER_ENABLED' would be a better check than + # 'development' for whether we need push bouncer secrets, + # but we're trying to avoid importing settings. + private_key = PrivateKey.generate() + private_key_str = Base64Encoder.encode(bytes(private_key)).decode("utf-8") + public_key_str = Base64Encoder.encode(bytes(private_key.public_key)).decode("utf-8") + add_secret( + "push_registration_encryption_keys", json.dumps({public_key_str: private_key_str}) + ) + if len(lines) == 0: print("generate_secrets: No new secrets to generate.") return diff --git a/uv.lock b/uv.lock index f4ee4b51c3..24cd588570 100644 --- a/uv.lock +++ b/uv.lock @@ -3300,6 +3300,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/9c/00301a6df26f0f8d5c5955192892241e803742e7c3da8c2c222efabc0df6/pymongo-4.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c38168263ed94a250fc5cf9c6d33adea8ab11c9178994da1c3481c2a49d235f8", size = 1011057, upload-time = "2025-06-16T18:16:07.917Z" }, ] +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, +] + [[package]] name = "pyoembed" version = "0.1.2" @@ -5320,6 +5340,7 @@ dev = [ { name = "pyinotify" }, { name = "pyjwt" }, { name = "pymongo" }, + { name = "pynacl" }, { name = "pyoembed" }, { name = "python-binary-memcached" }, { name = "python-dateutil" }, @@ -5440,6 +5461,7 @@ prod = [ { name = "pygments" }, { name = "pyjwt" }, { name = "pymongo" }, + { name = "pynacl" }, { name = "pyoembed" }, { name = "python-binary-memcached" }, { name = "python-dateutil" }, @@ -5540,6 +5562,7 @@ dev = [ { name = "pyinotify" }, { name = "pyjwt" }, { name = "pymongo" }, + { name = "pynacl" }, { name = "pyoembed" }, { name = "python-binary-memcached" }, { name = "python-dateutil" }, @@ -5661,6 +5684,7 @@ prod = [ { name = "pygments" }, { name = "pyjwt" }, { name = "pymongo" }, + { name = "pynacl" }, { name = "pyoembed" }, { name = "python-binary-memcached" }, { name = "python-dateutil" }, diff --git a/version.py b/version.py index 1bd04e158d..7931ee63ad 100644 --- a/version.py +++ b/version.py @@ -49,4 +49,4 @@ API_FEATURE_LEVEL = 401 # historical commits sharing the same major version, in which case a # minor version bump suffices. -PROVISION_VERSION = (333, 5) # bumped 2025-06-30 to add eslint-plugin-astro +PROVISION_VERSION = (333, 6) # bumped 2025-06-30 to add pynacl diff --git a/zilencer/management/commands/manage_push_registration_encryption_keys.py b/zilencer/management/commands/manage_push_registration_encryption_keys.py new file mode 100644 index 0000000000..d11e07d24d --- /dev/null +++ b/zilencer/management/commands/manage_push_registration_encryption_keys.py @@ -0,0 +1,87 @@ +import configparser +import json +from argparse import ArgumentParser +from typing import Any + +from django.conf import settings +from nacl.encoding import Base64Encoder +from nacl.public import PrivateKey +from typing_extensions import override + +from zerver.lib.management import ZulipBaseCommand + + +class Command(ZulipBaseCommand): + help = """ +Add or remove a key pair from the `push_registration_encryption_keys` map. + +Usage: +./manage.py manage_push_registration_encryption_keys --add +./manage.py manage_push_registration_encryption_keys --remove-key +""" + + @override + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument( + "--add", + action="store_true", + help="Add a new key pair to the `push_registration_encryption_keys` map.", + ) + parser.add_argument( + "--remove-key", + type=str, + metavar="PUBLIC_KEY", + help="Remove the key pair associated with the given public key from the `push_registration_encryption_keys` map.", + ) + + @override + def handle(self, *args: Any, **options: Any) -> None: + if not options["add"] and options["remove_key"] is None: + print("Error: Please provide either --add or --remove-key .") + return + + if settings.DEVELOPMENT: + SECRETS_FILENAME = "zproject/dev-secrets.conf" + else: + SECRETS_FILENAME = "/etc/zulip/zulip-secrets.conf" + + config = configparser.ConfigParser() + config.read(SECRETS_FILENAME) + push_registration_encryption_keys: dict[str, str] = json.loads( + config.get("secrets", "push_registration_encryption_keys", fallback="{}") + ) + + added_key_pair: tuple[str, str] | None = None + if options["add"]: + # Generate a new key-pair and store. + private_key = PrivateKey.generate() + private_key_str = Base64Encoder.encode(bytes(private_key)).decode("utf-8") + public_key_str = Base64Encoder.encode(bytes(private_key.public_key)).decode("utf-8") + push_registration_encryption_keys[public_key_str] = private_key_str + added_key_pair = (public_key_str, private_key_str) + + if options["remove_key"] is not None: + # Remove the key-pair for the given public key. + remove_key = options["remove_key"] + if remove_key not in push_registration_encryption_keys: + print("Error: No key pair found for the given public key.") + return + + del push_registration_encryption_keys[remove_key] + + config.set( + "secrets", + "push_registration_encryption_keys", + json.dumps(push_registration_encryption_keys), + ) + with open(SECRETS_FILENAME, "w") as secrets_file: + config.write(secrets_file) + + if added_key_pair is not None: + public_key_str, private_key_str = added_key_pair + print("Added a new key pair:") + print(f"- Public key: {public_key_str}") + print(f"- Private key: {private_key_str}") + + if options["remove_key"] is not None: + print(f"Removed the key pair for public key: {options['remove_key']}") diff --git a/zproject/computed_settings.py b/zproject/computed_settings.py index 74fbaf149b..79efebdd72 100644 --- a/zproject/computed_settings.py +++ b/zproject/computed_settings.py @@ -1,3 +1,4 @@ +import json import logging import os import sys @@ -101,6 +102,11 @@ SERVER_GENERATION = int(time.time()) ZULIP_ORG_KEY = get_secret("zulip_org_key") ZULIP_ORG_ID = get_secret("zulip_org_id") +raw_keys: str | None = get_secret("push_registration_encryption_keys") +PUSH_REGISTRATION_ENCRYPTION_KEYS: dict[str, str] | None = None +if raw_keys is not None: + PUSH_REGISTRATION_ENCRYPTION_KEYS = json.loads(raw_keys) + service_name_to_required_upload_level = { "security_alerts": AnalyticsDataUploadLevel.BASIC,