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.
This commit is contained in:
Prakhar Pratyush
2025-06-09 23:29:58 +05:30
committed by Tim Abbott
parent 86e771c982
commit 8b3cef554b
6 changed files with 136 additions and 1 deletions

View File

@@ -201,6 +201,9 @@ prod = [
# For using Missing sentinel
"pydantic-partials",
# For E2EE of push notifications
"pynacl",
]
docs = [
# Needed to build RTD docs

View File

@@ -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

24
uv.lock generated
View File

@@ -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" },

View File

@@ -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

View File

@@ -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 <public-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 <public-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']}")

View File

@@ -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,