Plain is headed towards 1.0! Subscribe for development updates →

Plain

Plain is a web framework for building products with Python.

With the core plain package you can build an app that:

With the official Plain ecosystem packages you can:

Learn more at plainframework.com.

  1"""
  2Functions for creating and restoring url-safe signed JSON objects.
  3
  4The format used looks like this:
  5
  6>>> signing.dumps("hello")
  7'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk'
  8
  9There are two components here, separated by a ':'. The first component is a
 10URLsafe base64 encoded JSON of the object passed to dumps(). The second
 11component is a base64 encoded hmac/SHA-256 hash of "$first_component:$secret"
 12
 13signing.loads(s) checks the signature and returns the deserialized object.
 14If the signature fails, a BadSignature exception is raised.
 15
 16>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk")
 17'hello'
 18>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified")
 19...
 20BadSignature: Signature "ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified" does not match
 21
 22You can optionally compress the JSON prior to base64 encoding it to save
 23space, using the compress=True argument. This checks if compression actually
 24helps and only applies compression if the result is a shorter string:
 25
 26>>> signing.dumps(list(range(1, 20)), compress=True)
 27'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ'
 28
 29The fact that the string is compressed is signalled by the prefixed '.' at the
 30start of the base64 JSON.
 31
 32There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'.
 33These functions make use of all of them.
 34"""
 35
 36import base64
 37import datetime
 38import json
 39import time
 40import zlib
 41
 42from plain.runtime import settings
 43from plain.utils.crypto import constant_time_compare, salted_hmac
 44from plain.utils.encoding import force_bytes
 45from plain.utils.module_loading import import_string
 46from plain.utils.regex_helper import _lazy_re_compile
 47
 48_SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$")
 49BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
 50
 51
 52class BadSignature(Exception):
 53    """Signature does not match."""
 54
 55    pass
 56
 57
 58class SignatureExpired(BadSignature):
 59    """Signature timestamp is older than required max_age."""
 60
 61    pass
 62
 63
 64def b62_encode(s):
 65    if s == 0:
 66        return "0"
 67    sign = "-" if s < 0 else ""
 68    s = abs(s)
 69    encoded = ""
 70    while s > 0:
 71        s, remainder = divmod(s, 62)
 72        encoded = BASE62_ALPHABET[remainder] + encoded
 73    return sign + encoded
 74
 75
 76def b62_decode(s):
 77    if s == "0":
 78        return 0
 79    sign = 1
 80    if s[0] == "-":
 81        s = s[1:]
 82        sign = -1
 83    decoded = 0
 84    for digit in s:
 85        decoded = decoded * 62 + BASE62_ALPHABET.index(digit)
 86    return sign * decoded
 87
 88
 89def b64_encode(s):
 90    return base64.urlsafe_b64encode(s).strip(b"=")
 91
 92
 93def b64_decode(s):
 94    pad = b"=" * (-len(s) % 4)
 95    return base64.urlsafe_b64decode(s + pad)
 96
 97
 98def base64_hmac(salt, value, key, algorithm="sha1"):
 99    return b64_encode(
100        salted_hmac(salt, value, key, algorithm=algorithm).digest()
101    ).decode()
102
103
104def _cookie_signer_key(key):
105    # SECRET_KEYS items may be str or bytes.
106    return b"plain.http.cookies" + force_bytes(key)
107
108
109def get_cookie_signer(salt="plain.signing.get_cookie_signer"):
110    Signer = import_string(settings.COOKIE_SIGNING_BACKEND)
111    return Signer(
112        key=_cookie_signer_key(settings.SECRET_KEY),
113        fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
114        salt=salt,
115    )
116
117
118class JSONSerializer:
119    """
120    Simple wrapper around json to be used in signing.dumps and
121    signing.loads.
122    """
123
124    def dumps(self, obj):
125        return json.dumps(obj, separators=(",", ":")).encode("latin-1")
126
127    def loads(self, data):
128        return json.loads(data.decode("latin-1"))
129
130
131def dumps(
132    obj, key=None, salt="plain.signing", serializer=JSONSerializer, compress=False
133):
134    """
135    Return URL-safe, hmac signed base64 compressed JSON string. If key is
136    None, use settings.SECRET_KEY instead. The hmac algorithm is the default
137    Signer algorithm.
138
139    If compress is True (not the default), check if compressing using zlib can
140    save some space. Prepend a '.' to signify compression. This is included
141    in the signature, to protect against zip bombs.
142
143    Salt can be used to namespace the hash, so that a signed string is
144    only valid for a given namespace. Leaving this at the default
145    value or re-using a salt value across different parts of your
146    application without good cause is a security risk.
147
148    The serializer is expected to return a bytestring.
149    """
150    return TimestampSigner(key=key, salt=salt).sign_object(
151        obj, serializer=serializer, compress=compress
152    )
153
154
155def loads(
156    s,
157    key=None,
158    salt="plain.signing",
159    serializer=JSONSerializer,
160    max_age=None,
161    fallback_keys=None,
162):
163    """
164    Reverse of dumps(), raise BadSignature if signature fails.
165
166    The serializer is expected to accept a bytestring.
167    """
168    return TimestampSigner(
169        key=key, salt=salt, fallback_keys=fallback_keys
170    ).unsign_object(
171        s,
172        serializer=serializer,
173        max_age=max_age,
174    )
175
176
177class Signer:
178    def __init__(
179        self,
180        *,
181        key=None,
182        sep=":",
183        salt=None,
184        algorithm="sha256",
185        fallback_keys=None,
186    ):
187        self.key = key or settings.SECRET_KEY
188        self.fallback_keys = (
189            fallback_keys
190            if fallback_keys is not None
191            else settings.SECRET_KEY_FALLBACKS
192        )
193        self.sep = sep
194        self.salt = salt or f"{self.__class__.__module__}.{self.__class__.__name__}"
195        self.algorithm = algorithm
196
197        if _SEP_UNSAFE.match(self.sep):
198            raise ValueError(
199                "Unsafe Signer separator: %r (cannot be empty or consist of "
200                "only A-z0-9-_=)" % sep,
201            )
202
203    def signature(self, value, key=None):
204        key = key or self.key
205        return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm)
206
207    def sign(self, value):
208        return f"{value}{self.sep}{self.signature(value)}"
209
210    def unsign(self, signed_value):
211        if self.sep not in signed_value:
212            raise BadSignature('No "%s" found in value' % self.sep)
213        value, sig = signed_value.rsplit(self.sep, 1)
214        for key in [self.key, *self.fallback_keys]:
215            if constant_time_compare(sig, self.signature(value, key)):
216                return value
217        raise BadSignature('Signature "%s" does not match' % sig)
218
219    def sign_object(self, obj, serializer=JSONSerializer, compress=False):
220        """
221        Return URL-safe, hmac signed base64 compressed JSON string.
222
223        If compress is True (not the default), check if compressing using zlib
224        can save some space. Prepend a '.' to signify compression. This is
225        included in the signature, to protect against zip bombs.
226
227        The serializer is expected to return a bytestring.
228        """
229        data = serializer().dumps(obj)
230        # Flag for if it's been compressed or not.
231        is_compressed = False
232
233        if compress:
234            # Avoid zlib dependency unless compress is being used.
235            compressed = zlib.compress(data)
236            if len(compressed) < (len(data) - 1):
237                data = compressed
238                is_compressed = True
239        base64d = b64_encode(data).decode()
240        if is_compressed:
241            base64d = "." + base64d
242        return self.sign(base64d)
243
244    def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs):
245        # Signer.unsign() returns str but base64 and zlib compression operate
246        # on bytes.
247        base64d = self.unsign(signed_obj, **kwargs).encode()
248        decompress = base64d[:1] == b"."
249        if decompress:
250            # It's compressed; uncompress it first.
251            base64d = base64d[1:]
252        data = b64_decode(base64d)
253        if decompress:
254            data = zlib.decompress(data)
255        return serializer().loads(data)
256
257
258class TimestampSigner(Signer):
259    def timestamp(self):
260        return b62_encode(int(time.time()))
261
262    def sign(self, value):
263        value = f"{value}{self.sep}{self.timestamp()}"
264        return super().sign(value)
265
266    def unsign(self, value, max_age=None):
267        """
268        Retrieve original value and check it wasn't signed more
269        than max_age seconds ago.
270        """
271        result = super().unsign(value)
272        value, timestamp = result.rsplit(self.sep, 1)
273        timestamp = b62_decode(timestamp)
274        if max_age is not None:
275            if isinstance(max_age, datetime.timedelta):
276                max_age = max_age.total_seconds()
277            # Check timestamp is not older than max_age
278            age = time.time() - timestamp
279            if age > max_age:
280                raise SignatureExpired(f"Signature age {age} > {max_age} seconds")
281        return value