Files
zulip/zerver/models/push_notifications.py
Prakhar Pratyush 7ebdca52e8 push_notification: Use symmetric cryptography to encrypt notifications.
Earlier we were using asymmetric cryptography.

We were using libsodium "sealed box" which is unauthenticated
by design. The sender could have been anyone, as long as they
had the receiver's public key.

We had authenticity but only because the device's public key
is effectively kept secret. We were relying on the public key
being kept secret - which was a security risk. It's easy to
end up with code somewhere that treats the public key as public,
and can leak it.

This commit makes changes to use symmetric cryptography -
libsodium's `crypto_secretbox_easy` which provides authenticated
encryption using XSalsa20 and Poly1305.

`push_public_key` is replaced with `push_key` and it represents
a base64 encoded 33-byte value: one-byte prefix followed by 32-byte
secret key generated by the client.

The prefix `0x31` indicates the current cryptosystem in use.
It allows for future extensibility - for example, `0x32` could denote
a different cryptosystem.

Involves API changes to replace the `push_public_key` parameter
with `push_key` in `/api/v1/mobile_push/register` endpoint.

Signed-off-by: Prakhar Pratyush <prakhar@zulip.com>
2025-11-07 12:00:39 -08:00

148 lines
5.2 KiB
Python

from typing import Literal
from django.db import models
from django.db.models import CASCADE, F, Q, UniqueConstraint
from django.db.models.functions import Lower
from zerver.lib.exceptions import (
InvalidBouncerPublicKeyError,
InvalidEncryptedPushRegistrationError,
RequestExpiredError,
)
from zerver.models.users import UserProfile
class AbstractPushDeviceToken(models.Model):
APNS = 1
FCM = 2
KINDS = (
(APNS, "apns"),
# The string value in the database is "gcm" for legacy reasons.
# TODO: We should migrate it.
(FCM, "gcm"),
)
kind = models.PositiveSmallIntegerField(choices=KINDS)
# The token is a unique device-specific token that is
# sent to us from each device:
# - APNS token if kind == APNS
# - FCM registration id if kind == FCM
token = models.CharField(max_length=4096, db_index=True)
# TODO: last_updated should be renamed date_created, since it is
# no longer maintained as a last_updated value.
last_updated = models.DateTimeField(auto_now=True)
# [optional] Contains the app id of the device if it is an iOS device
ios_app_id = models.TextField(null=True)
class Meta:
abstract = True
class PushDeviceToken(AbstractPushDeviceToken):
# The user whose device this is
user = models.ForeignKey(UserProfile, db_index=True, on_delete=CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
"user_id",
"kind",
Lower(F("token")),
name="zerver_pushdevicetoken_apns_user_kind_token",
condition=Q(kind=AbstractPushDeviceToken.APNS),
),
models.UniqueConstraint(
"user_id",
"kind",
"token",
name="zerver_pushdevicetoken_fcm_user_kind_token",
condition=Q(kind=AbstractPushDeviceToken.FCM),
),
]
class AbstractPushDevice(models.Model):
class TokenKind(models.TextChoices):
APNS = "apns", "APNs"
FCM = "fcm", "FCM"
token_kind = models.CharField(max_length=4, choices=TokenKind.choices)
# 64-bit random integer ID generated by the client; will only be
# guaranteed to be unique within the client's own table of accounts.
push_account_id = models.BigIntegerField()
class Meta:
abstract = True
class PushDevice(AbstractPushDevice):
"""Core zulip server table storing registrations that are potentially
registered with the mobile push notifications bouncer service.
Each row corresponds to an account on an install of the app
that has attempted to register with the bouncer to receive
mobile push notifications.
"""
# The user on this server to whom this PushDevice belongs.
user = models.ForeignKey(UserProfile, on_delete=CASCADE)
# Key to use to encrypt notifications for delivery to this device.
# Consists of a 1-byte prefix identifying the symmetric cryptosystem
# in use, followed by the secret key.
# Prefix Cryptosystem
# 0x31 libsodium's `crypto_secretbox_easy`
push_key = models.BinaryField()
# `device_id` of the corresponding `RemotePushDevice`
# row created after successful registration to bouncer.
# Set to NULL while registration to bouncer is in progress or failed.
bouncer_device_id = models.BigIntegerField(null=True)
class ErrorCode(models.TextChoices):
INVALID_BOUNCER_PUBLIC_KEY = InvalidBouncerPublicKeyError.code.name
INVALID_ENCRYPTED_PUSH_REGISTRATION = InvalidEncryptedPushRegistrationError.code.name
REQUEST_EXPIRED = RequestExpiredError.code.name
# The error code returned when registration to bouncer fails.
# Set to NULL if the registration is in progress or successful.
error_code = models.CharField(max_length=100, choices=ErrorCode.choices, null=True)
class Meta:
constraints = [
UniqueConstraint(
# In theory, there's possibility that a user with
# two different devices having the same 'push_account_id'
# generated by the client to register. But the API treats
# that as idempotent request.
# We treat (user, push_account_id) as a unique registration.
#
# Also, the unique index created is used by queries in `get_push_accounts`,
# `register_push_device`, `handle_register_push_device_to_bouncer`, and
# `send_e2ee_test_push_notification_api`.
fields=["user", "push_account_id"],
name="unique_push_device_user_push_account_id",
),
]
indexes = [
models.Index(
# Used in 'send_push_notifications' and `send_e2ee_test_push_notification_api`.
fields=["user", "bouncer_device_id"],
condition=Q(bouncer_device_id__isnull=False),
name="zerver_pushdevice_user_bouncer_device_id_idx",
),
]
@property
def status(self) -> Literal["active", "pending", "failed"]:
if self.error_code is not None:
return "failed"
elif self.bouncer_device_id is None:
return "pending"
return "active"