Updates
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Message Signing Serializer."""
|
||||
from kombu.serialization import disable_insecure_serializers as _disable_insecure_serializers
|
||||
from kombu.serialization import registry
|
||||
|
||||
from celery.exceptions import ImproperlyConfigured
|
||||
|
||||
from .serialization import register_auth # : need cryptography first
|
||||
|
||||
CRYPTOGRAPHY_NOT_INSTALLED = """\
|
||||
You need to install the cryptography library to use the auth serializer.
|
||||
Please install by:
|
||||
|
||||
$ pip install cryptography
|
||||
"""
|
||||
|
||||
SECURITY_SETTING_MISSING = """\
|
||||
Sorry, but you have to configure the
|
||||
* security_key
|
||||
* security_certificate, and the
|
||||
* security_cert_store
|
||||
configuration settings to use the auth serializer.
|
||||
|
||||
Please see the configuration reference for more information.
|
||||
"""
|
||||
|
||||
SETTING_MISSING = """\
|
||||
You have to configure a special task serializer
|
||||
for signing and verifying tasks:
|
||||
* task_serializer = 'auth'
|
||||
|
||||
You have to accept only tasks which are serialized with 'auth'.
|
||||
There is no point in signing messages if they are not verified.
|
||||
* accept_content = ['auth']
|
||||
"""
|
||||
|
||||
__all__ = ('setup_security',)
|
||||
|
||||
try:
|
||||
import cryptography # noqa
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(CRYPTOGRAPHY_NOT_INSTALLED)
|
||||
|
||||
|
||||
def setup_security(allowed_serializers=None, key=None, key_password=None, cert=None, store=None,
|
||||
digest=None, serializer='json', app=None):
|
||||
"""See :meth:`@Celery.setup_security`."""
|
||||
if app is None:
|
||||
from celery import current_app
|
||||
app = current_app._get_current_object()
|
||||
|
||||
_disable_insecure_serializers(allowed_serializers)
|
||||
|
||||
# check conf for sane security settings
|
||||
conf = app.conf
|
||||
if conf.task_serializer != 'auth' or conf.accept_content != ['auth']:
|
||||
raise ImproperlyConfigured(SETTING_MISSING)
|
||||
|
||||
key = key or conf.security_key
|
||||
key_password = key_password or conf.security_key_password
|
||||
cert = cert or conf.security_certificate
|
||||
store = store or conf.security_cert_store
|
||||
digest = digest or conf.security_digest
|
||||
|
||||
if not (key and cert and store):
|
||||
raise ImproperlyConfigured(SECURITY_SETTING_MISSING)
|
||||
|
||||
with open(key) as kf:
|
||||
with open(cert) as cf:
|
||||
register_auth(kf.read(), key_password, cf.read(), store, digest, serializer)
|
||||
registry._set_default_serializer('auth')
|
||||
|
||||
|
||||
def disable_untrusted_serializers(whitelist=None):
|
||||
_disable_insecure_serializers(allowed=whitelist)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,113 @@
|
||||
"""X.509 certificates."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import glob
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Iterator
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
from kombu.utils.encoding import bytes_to_str, ensure_bytes
|
||||
|
||||
from celery.exceptions import SecurityError
|
||||
|
||||
from .utils import reraise_errors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.dsa import DSAPublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
|
||||
from cryptography.hazmat.primitives.hashes import HashAlgorithm
|
||||
|
||||
|
||||
__all__ = ('Certificate', 'CertStore', 'FSCertStore')
|
||||
|
||||
|
||||
class Certificate:
|
||||
"""X.509 certificate."""
|
||||
|
||||
def __init__(self, cert: str) -> None:
|
||||
with reraise_errors(
|
||||
'Invalid certificate: {0!r}', errors=(ValueError,)
|
||||
):
|
||||
self._cert = load_pem_x509_certificate(
|
||||
ensure_bytes(cert), backend=default_backend())
|
||||
|
||||
if not isinstance(self._cert.public_key(), rsa.RSAPublicKey):
|
||||
raise ValueError("Non-RSA certificates are not supported.")
|
||||
|
||||
def has_expired(self) -> bool:
|
||||
"""Check if the certificate has expired."""
|
||||
return datetime.datetime.now(datetime.timezone.utc) >= self._cert.not_valid_after_utc
|
||||
|
||||
def get_pubkey(self) -> (
|
||||
DSAPublicKey | EllipticCurvePublicKey | Ed448PublicKey | Ed25519PublicKey | RSAPublicKey
|
||||
):
|
||||
return self._cert.public_key()
|
||||
|
||||
def get_serial_number(self) -> int:
|
||||
"""Return the serial number in the certificate."""
|
||||
return self._cert.serial_number
|
||||
|
||||
def get_issuer(self) -> str:
|
||||
"""Return issuer (CA) as a string."""
|
||||
return ' '.join(x.value for x in self._cert.issuer)
|
||||
|
||||
def get_id(self) -> str:
|
||||
"""Serial number/issuer pair uniquely identifies a certificate."""
|
||||
return f'{self.get_issuer()} {self.get_serial_number()}'
|
||||
|
||||
def verify(self, data: bytes, signature: bytes, digest: HashAlgorithm | Prehashed) -> None:
|
||||
"""Verify signature for string containing data."""
|
||||
with reraise_errors('Bad signature: {0!r}'):
|
||||
|
||||
pad = padding.PSS(
|
||||
mgf=padding.MGF1(digest),
|
||||
salt_length=padding.PSS.MAX_LENGTH)
|
||||
|
||||
self.get_pubkey().verify(signature, ensure_bytes(data), pad, digest)
|
||||
|
||||
|
||||
class CertStore:
|
||||
"""Base class for certificate stores."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._certs: dict[str, Certificate] = {}
|
||||
|
||||
def itercerts(self) -> Iterator[Certificate]:
|
||||
"""Return certificate iterator."""
|
||||
yield from self._certs.values()
|
||||
|
||||
def __getitem__(self, id: str) -> Certificate:
|
||||
"""Get certificate by id."""
|
||||
try:
|
||||
return self._certs[bytes_to_str(id)]
|
||||
except KeyError:
|
||||
raise SecurityError(f'Unknown certificate: {id!r}')
|
||||
|
||||
def add_cert(self, cert: Certificate) -> None:
|
||||
cert_id = bytes_to_str(cert.get_id())
|
||||
if cert_id in self._certs:
|
||||
raise SecurityError(f'Duplicate certificate: {id!r}')
|
||||
self._certs[cert_id] = cert
|
||||
|
||||
|
||||
class FSCertStore(CertStore):
|
||||
"""File system certificate store."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
super().__init__()
|
||||
if os.path.isdir(path):
|
||||
path = os.path.join(path, '*')
|
||||
for p in glob.glob(path):
|
||||
with open(p) as f:
|
||||
cert = Certificate(f.read())
|
||||
if cert.has_expired():
|
||||
raise SecurityError(
|
||||
f'Expired certificate: {cert.get_id()!r}')
|
||||
self.add_cert(cert)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Private keys for the security serializer."""
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from kombu.utils.encoding import ensure_bytes
|
||||
|
||||
from .utils import reraise_errors
|
||||
|
||||
__all__ = ('PrivateKey',)
|
||||
|
||||
|
||||
class PrivateKey:
|
||||
"""Represents a private key."""
|
||||
|
||||
def __init__(self, key, password=None):
|
||||
with reraise_errors(
|
||||
'Invalid private key: {0!r}', errors=(ValueError,)
|
||||
):
|
||||
self._key = serialization.load_pem_private_key(
|
||||
ensure_bytes(key),
|
||||
password=ensure_bytes(password),
|
||||
backend=default_backend())
|
||||
|
||||
if not isinstance(self._key, rsa.RSAPrivateKey):
|
||||
raise ValueError("Non-RSA keys are not supported.")
|
||||
|
||||
def sign(self, data, digest):
|
||||
"""Sign string containing data."""
|
||||
with reraise_errors('Unable to sign data: {0!r}'):
|
||||
|
||||
pad = padding.PSS(
|
||||
mgf=padding.MGF1(digest),
|
||||
salt_length=padding.PSS.MAX_LENGTH)
|
||||
|
||||
return self._key.sign(ensure_bytes(data), pad, digest)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Secure serializer."""
|
||||
from kombu.serialization import dumps, loads, registry
|
||||
from kombu.utils.encoding import bytes_to_str, ensure_bytes, str_to_bytes
|
||||
|
||||
from celery.app.defaults import DEFAULT_SECURITY_DIGEST
|
||||
from celery.utils.serialization import b64decode, b64encode
|
||||
|
||||
from .certificate import Certificate, FSCertStore
|
||||
from .key import PrivateKey
|
||||
from .utils import get_digest_algorithm, reraise_errors
|
||||
|
||||
__all__ = ('SecureSerializer', 'register_auth')
|
||||
|
||||
# Note: we guarantee that this value won't appear in the serialized data,
|
||||
# so we can use it as a separator.
|
||||
# If you change this value, make sure it's not present in the serialized data.
|
||||
DEFAULT_SEPARATOR = str_to_bytes("\x00\x01")
|
||||
|
||||
|
||||
class SecureSerializer:
|
||||
"""Signed serializer."""
|
||||
|
||||
def __init__(self, key=None, cert=None, cert_store=None,
|
||||
digest=DEFAULT_SECURITY_DIGEST, serializer='json'):
|
||||
self._key = key
|
||||
self._cert = cert
|
||||
self._cert_store = cert_store
|
||||
self._digest = get_digest_algorithm(digest)
|
||||
self._serializer = serializer
|
||||
|
||||
def serialize(self, data):
|
||||
"""Serialize data structure into string."""
|
||||
assert self._key is not None
|
||||
assert self._cert is not None
|
||||
with reraise_errors('Unable to serialize: {0!r}', (Exception,)):
|
||||
content_type, content_encoding, body = dumps(
|
||||
data, serializer=self._serializer)
|
||||
|
||||
# What we sign is the serialized body, not the body itself.
|
||||
# this way the receiver doesn't have to decode the contents
|
||||
# to verify the signature (and thus avoiding potential flaws
|
||||
# in the decoding step).
|
||||
body = ensure_bytes(body)
|
||||
return self._pack(body, content_type, content_encoding,
|
||||
signature=self._key.sign(body, self._digest),
|
||||
signer=self._cert.get_id())
|
||||
|
||||
def deserialize(self, data):
|
||||
"""Deserialize data structure from string."""
|
||||
assert self._cert_store is not None
|
||||
with reraise_errors('Unable to deserialize: {0!r}', (Exception,)):
|
||||
payload = self._unpack(data)
|
||||
signature, signer, body = (payload['signature'],
|
||||
payload['signer'],
|
||||
payload['body'])
|
||||
self._cert_store[signer].verify(body, signature, self._digest)
|
||||
return loads(body, payload['content_type'],
|
||||
payload['content_encoding'], force=True)
|
||||
|
||||
def _pack(self, body, content_type, content_encoding, signer, signature,
|
||||
sep=DEFAULT_SEPARATOR):
|
||||
fields = sep.join(
|
||||
ensure_bytes(s) for s in [b64encode(signer), b64encode(signature),
|
||||
content_type, content_encoding, body]
|
||||
)
|
||||
return b64encode(fields)
|
||||
|
||||
def _unpack(self, payload, sep=DEFAULT_SEPARATOR):
|
||||
raw_payload = b64decode(ensure_bytes(payload))
|
||||
v = raw_payload.split(sep, maxsplit=4)
|
||||
return {
|
||||
'signer': b64decode(v[0]),
|
||||
'signature': b64decode(v[1]),
|
||||
'content_type': bytes_to_str(v[2]),
|
||||
'content_encoding': bytes_to_str(v[3]),
|
||||
'body': v[4],
|
||||
}
|
||||
|
||||
|
||||
def register_auth(key=None, key_password=None, cert=None, store=None,
|
||||
digest=DEFAULT_SECURITY_DIGEST,
|
||||
serializer='json'):
|
||||
"""Register security serializer."""
|
||||
s = SecureSerializer(key and PrivateKey(key, password=key_password),
|
||||
cert and Certificate(cert),
|
||||
store and FSCertStore(store),
|
||||
digest, serializer=serializer)
|
||||
registry.register('auth', s.serialize, s.deserialize,
|
||||
content_type='application/data',
|
||||
content_encoding='utf-8')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Utilities used by the message signing serializer."""
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
from celery.exceptions import SecurityError, reraise
|
||||
|
||||
__all__ = ('get_digest_algorithm', 'reraise_errors',)
|
||||
|
||||
|
||||
def get_digest_algorithm(digest='sha256'):
|
||||
"""Convert string to hash object of cryptography library."""
|
||||
assert digest is not None
|
||||
return getattr(hashes, digest.upper())()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def reraise_errors(msg='{0!r}', errors=None):
|
||||
"""Context reraising crypto errors as :exc:`SecurityError`."""
|
||||
errors = (cryptography.exceptions,) if errors is None else errors
|
||||
try:
|
||||
yield
|
||||
except errors as exc:
|
||||
reraise(SecurityError,
|
||||
SecurityError(msg.format(exc)),
|
||||
sys.exc_info()[2])
|
||||
Reference in New Issue
Block a user