""" Functions for creating and restoring url-safe signed JSON objects. The format used looks like this: >>> signing.dumps("hello") 'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk' There are two components here, separated by a ':'. The first component is a URLsafe base64 encoded JSON of the object passed to dumps(). The second component is a base64 encoded hmac/SHA-256 hash of "$first_component:$secret" signing.loads(s) checks the signature and returns the deserialized object. If the signature fails, a BadSignature exception is raised. >>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk") 'hello' >>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified") ... BadSignature: Signature "ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified" does not match You can optionally compress the JSON prior to base64 encoding it to save space, using the compress=True argument. This checks if compression actually helps and only applies compression if the result is a shorter string: >>> signing.dumps(list(range(1, 20)), compress=True) '.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ' The fact that the string is compressed is signalled by the prefixed '.' at the start of the base64 JSON. There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'. These functions make use of all of them. """ import base64 import datetime import json import time import warnings import zlib from django.conf import settings from django.utils.crypto import constant_time_compare, salted_hmac from django.utils.deprecation import RemovedInDjango51Warning from django.utils.encoding import force_bytes from django.utils.module_loading import import_string from django.utils.regex_helper import _lazy_re_compile _SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$") BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" class BadSignature(Exception): """Signature does not match.""" pass class SignatureExpired(BadSignature): """Signature timestamp is older than required max_age.""" pass def b62_encode(s): if s == 0: return "0" sign = "-" if s < 0 else "" s = abs(s) encoded = "" while s > 0: s, remainder = divmod(s, 62) encoded = BASE62_ALPHABET[remainder] + encoded return sign + encoded def b62_decode(s): if s == "0": return 0 sign = 1 if s[0] == "-": s = s[1:] sign = -1 decoded = 0 for digit in s: decoded = decoded * 62 + BASE62_ALPHABET.index(digit) return sign * decoded def b64_encode(s): return base64.urlsafe_b64encode(s).strip(b"=") def b64_decode(s): pad = b"=" * (-len(s) % 4) return base64.urlsafe_b64decode(s + pad) def base64_hmac(salt, value, key, algorithm="sha1"): return b64_encode( salted_hmac(salt, value, key, algorithm=algorithm).digest() ).decode() def _cookie_signer_key(key): # SECRET_KEYS items may be str or bytes. return b"django.http.cookies" + force_bytes(key) def get_cookie_signer(salt="django.core.signing.get_cookie_signer"): Signer = import_string(settings.SIGNING_BACKEND) return Signer( key=_cookie_signer_key(settings.SECRET_KEY), fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS), salt=salt, ) class JSONSerializer: """ Simple wrapper around json to be used in signing.dumps and signing.loads. """ def dumps(self, obj): return json.dumps(obj, separators=(",", ":")).encode("latin-1") def loads(self, data): return json.loads(data.decode("latin-1")) def dumps( obj, key=None, salt="django.core.signing", serializer=JSONSerializer, compress=False ): """ Return URL-safe, hmac signed base64 compressed JSON string. If key is None, use settings.SECRET_KEY instead. The hmac algorithm is the default Signer algorithm. If compress is True (not the default), check if compressing using zlib can save some space. Prepend a '.' to signify compression. This is included in the signature, to protect against zip bombs. Salt can be used to namespace the hash, so that a signed string is only valid for a given namespace. Leaving this at the default value or re-using a salt value across different parts of your application without good cause is a security risk. The serializer is expected to return a bytestring. """ return TimestampSigner(key=key, salt=salt).sign_object( obj, serializer=serializer, compress=compress ) def loads( s, key=None, salt="django.core.signing", serializer=JSONSerializer, max_age=None, fallback_keys=None, ): """ Reverse of dumps(), raise BadSignature if signature fails. The serializer is expected to accept a bytestring. """ return TimestampSigner( key=key, salt=salt, fallback_keys=fallback_keys ).unsign_object( s, serializer=serializer, max_age=max_age, ) class Signer: # RemovedInDjango51Warning: When the deprecation ends, replace with: # def __init__( # self, *, key=None, sep=":", salt=None, algorithm=None, fallback_keys=None # ): def __init__( self, *args, key=None, sep=":", salt=None, algorithm=None, fallback_keys=None, ): self.key = key or settings.SECRET_KEY self.fallback_keys = ( fallback_keys if fallback_keys is not None else settings.SECRET_KEY_FALLBACKS ) self.sep = sep self.salt = salt or "%s.%s" % ( self.__class__.__module__, self.__class__.__name__, ) self.algorithm = algorithm or "sha256" # RemovedInDjango51Warning. if args: warnings.warn( f"Passing positional arguments to {self.__class__.__name__} is " f"deprecated.", RemovedInDjango51Warning, stacklevel=2, ) for arg, attr in zip( args, ["key", "sep", "salt", "algorithm", "fallback_keys"] ): if arg or attr == "sep": setattr(self, attr, arg) if _SEP_UNSAFE.match(self.sep): raise ValueError( "Unsafe Signer separator: %r (cannot be empty or consist of " "only A-z0-9-_=)" % sep, ) def signature(self, value, key=None): key = key or self.key return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm) def sign(self, value): return "%s%s%s" % (value, self.sep, self.signature(value)) def unsign(self, signed_value): if self.sep not in signed_value: raise BadSignature('No "%s" found in value' % self.sep) value, sig = signed_value.rsplit(self.sep, 1) for key in [self.key, *self.fallback_keys]: if constant_time_compare(sig, self.signature(value, key)): return value raise BadSignature('Signature "%s" does not match' % sig) def sign_object(self, obj, serializer=JSONSerializer, compress=False): """ Return URL-safe, hmac signed base64 compressed JSON string. If compress is True (not the default), check if compressing using zlib can save some space. Prepend a '.' to signify compression. This is included in the signature, to protect against zip bombs. The serializer is expected to return a bytestring. """ data = serializer().dumps(obj) # Flag for if it's been compressed or not. is_compressed = False if compress: # Avoid zlib dependency unless compress is being used. compressed = zlib.compress(data) if len(compressed) < (len(data) - 1): data = compressed is_compressed = True base64d = b64_encode(data).decode() if is_compressed: base64d = "." + base64d return self.sign(base64d) def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs): # Signer.unsign() returns str but base64 and zlib compression operate # on bytes. base64d = self.unsign(signed_obj, **kwargs).encode() decompress = base64d[:1] == b"." if decompress: # It's compressed; uncompress it first. base64d = base64d[1:] data = b64_decode(base64d) if decompress: data = zlib.decompress(data) return serializer().loads(data) class TimestampSigner(Signer): def timestamp(self): return b62_encode(int(time.time())) def sign(self, value): value = "%s%s%s" % (value, self.sep, self.timestamp()) return super().sign(value) def unsign(self, value, max_age=None): """ Retrieve original value and check it wasn't signed more than max_age seconds ago. """ result = super().unsign(value) value, timestamp = result.rsplit(self.sep, 1) timestamp = b62_decode(timestamp) if max_age is not None: if isinstance(max_age, datetime.timedelta): max_age = max_age.total_seconds() # Check timestamp is not older than max_age age = time.time() - timestamp if age > max_age: raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age)) return value