I'm pursing expert feedback on the crypto used in Django signing /
signed cookies. Here's the e-mail I'm sending out to various mailing
lists. I'll also link to this post from a number of places and see if
I can get some smart eyes on it that way.

====================================

Hello,

We've been working to add string signing and signed cookies to the
core of the Django web framework <http://www.djangoproject.com/>. We
have a design and an implementation but we've decided we should get
some feedback from security experts before adding it to the project.

I'd really appreciate any feedback people can give us on the approach
we are using. We're planning on adding two APIs to Django - a low-
level API for signing and checking signatures on strings, and a high-
level API for setting and reading signed cookies.

The low-level API can be seen here:

http://github.com/simonw/django/blob/signed/django/utils/signed.py

The core signing logic lives in the Signer class on line 89:

http://github.com/simonw/django/blob/signed/django/utils/signed.py#L89

Here are the corresponding unit tests:

http://github.com/simonw/django/blob/signed/tests/regressiontests/utils/signed.py

To summarise, the low-level API is used like this:

>>> signer = Signer('my-top-secret-key')
>>> value = u'Hello world'
>>> signed_value = signer.sign(value)
>>> signed_value
'Hello world:DeYFglqKoK8DLYD0nQijugnZaTc'
>>> unsigned_value = signer.unsign(signed_value)
>>> unsigned_value
u'Hello world'

A few notes on how the code works:

Signing strings
---------------

Actual signatures are generated using hmac/sha1. We encode the
resulting signature with base64 (rather than the more common
hexdigest). We do this because the signatures are expected to be used
in both URLs and cookies, where every character counts. The relevant
functions are:

def b64_encode(s):
    return base64.urlsafe_b64encode(s).strip('=')

def base64_hmac(value, key):
    return b64_encode(
        (hmac.new(key, value, sha_constructor).digest())
    )

Picking a key to use for the signature
--------------------------------------

We've been (I think) pretty paranoid about picking the key used for
each signature. Every Django installation has a SECRET_KEY setting
which is designed to be used for this kind of purpose. Rather than use
the SECRET_KEY directly as the key for signatures, we derive a key for
each signature based on the SECRET_KEY plus a salt.

We do this because we're worried about attackers abusing a component
of a Django application that allows them to control what is being
signed and hence increase their information about the key. I don't
know if hmac/sha1 can be attacked in this way, but I'm pretty sure
this is a good idea. If you have any tips as to how I can explain the
benefits of this in the documentation for the feature they would be
greatly appreciated!

By default, the key we actually use for the signature (and hence pass
to the base64_hmac function above) is sha1('signer' + SECRET_KEY +
salt). 'salt' defaults to the empty string but users will be
encouraged to provide a salt every time they use the low-level signing
API - Django functionality that calls it (such as the signed cookie
implementation) will always use a salt.

Here's the signature method from the Signer class in full:

def signature(self, value, salt=''):
    # Derive a new key from the SECRET_KEY, using the optional salt
    key = sha_constructor('signer' + self.key + salt).hexdigest()
    return base64_hmac(value, key)

Appending the signature to a string
-----------------------------------

API users are not expected to call that signature() method directly
though. Instead, we provide two methods - sign() and unsign() - which
handle appending the signature to the string.

Here they are in full:

def sign(self, value, salt='', sep=':'):
    value = smart_str(value)
    return '%s%s%s' % (
        value, sep, self.signature(value, salt=salt)
    )

def unsign(self, signed_value, salt='', sep=':'):
    signed_value = smart_str(signed_value)
    if not sep in signed_value:
        raise BadSignature, "No '%s' found in value" % sep
    value, sig = signed_value.rsplit(sep, 1)
    expected = self.signature(value, salt=salt)
    if sig != expected:
        # Important: do NOT include the expected sig in the exception
        # message, since it might leak up to an attacker!
        raise BadSignature, 'Signature "%s" does not match' % sig
    else:
        return force_unicode(value)

The smart_str method simply ensures that any Python unicode strings
are converted to UTF8 bytestrings before being signed. force_unicode
converts utf8 bytestrings back to Python unicode strings.

As you can see, the separator between the signature and the value
defaults to being a ':'. I plan to move it from being an argument on
the sign and unsign methods to being an argument to the Signer class
constructor.

Including a timestamp with the signature
----------------------------------------

The second class in the signed.py module is a subclass of Signer that
appends a unix timestamp to the string before it is signed. This
allows the unsign() method to specify a max_age - if the signed value
is older than that max_age, it is discarded.

Here's the code:

class TimestampSigner(Signer):
    def timestamp(self):
        return baseconv.base62.from_int(int(time.time()))

    def sign(self, value, salt='', sep=':'):
        value = smart_str('%s%s%s' % (value, sep, self.timestamp()))
        return '%s%s%s' % (
            value, sep, self.signature(value, salt=salt)
        )

    def unsign(self, value, salt='', sep=':', max_age=None):
        value, timestamp = super(TimestampSigner, self).unsign(
            value, salt=salt, sep=sep
        ).rsplit(sep, 1)
        timestamp = baseconv.base62.to_int(timestamp)
        if max_age is not None:
            # 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

As you can see, the timestamp is appended to the value before it is
calculated, and a max_age can be passed to the unsign() method and
will be checked before the string is returned.

Again, as a space saving the unix timestamp is a base62 encoded - this
shrinks it and makes it suitable for inclusion in a URL (for example).

Signing cookies
---------------

Signed cookies are provided using two new methods on core Django
objects: a get_signed_cookie() method on the Django request object and
a set_signed_cookie() method on the Django response. Here's what a
very simple Django view might look like that reads the name from a
signed cookie and sets that cookie if a new name has been provided in
a POST parameter:

def index(request):
    name = request.get_signed_cookie('name')
    set_cookie = False
    if name in request.POST:
        name = request.POST['name']
        set_cookie = True
    response = render_to_response('index.html', {
        'name': name,
    })
    if set_cookie:
        response.set_signed_cookie('name', name)
    return response

The get_signed_cookie() method is implemented here:

http://github.com/simonw/django/blob/signed/django/http/__init__.py#L66

And set_signed_cookie() is here:

http://github.com/simonw/django/blob/signed/django/http/__init__.py#L388

Both of these methods take an optional 'salt' argument - but even if
you don't provide a salt, the name of the cookie will be used as the
salt. If you DO provide a salt the actual salt used will be
cookie_name + your_salt.

SECRET_KEY rotation
-------------------

I'm still working out the details for this, but the final feature we
want to provide is a mechanism for rolling out a new SECRET_KEY
without breaking everything that has been signed with an old one. I
believe this is best practice, but I'm eager to hear if it isn't.

The plan is to support an optional OLD_SECRET_KEYS setting which is a
list of old secret keys which should still work for unsigning but
should not be used for signing.

The UpgradingSigner class here is a start at this code:

http://github.com/simonw/django/blob/signed/django/utils/signed.py#L142

A piece of Django middleware will be provided that quietly "upgrades"
any signed cookies that have been signed using one of the older keys,
replacing them with a cookie signed with the current SECRET_KEY. This
middleware will need to know the names of the cookies that should be
upgraded and what salt was used to generate them.

The process for rolling out a new SECRET_KEY then will be this:

1. Put the current secret key in OLD_SECRET_KEYS:

OLD_SECRET_KEYS = ['your-current-secret-key']

2. Put the NEW secret key in SECRET_KEY:

SECRET_KEYS = 'your-new-secret-key'

3. Turn on the Django signed cookie upgrading middleware:

MIDDLEWARE_CLASSES += (
    'django.middleware.signedcookies.UpgradeOldSignedCookies',
)

4. Tell that middleware which cookies to upgrade:

SIGNED_COOKIES_TO_UPGRADE = (
    ('name', 'salt-for-name'),
)

Now wait a week while a bunch of cookies get upgraded, then remove the
key from OLD_SECRET_KEYS.

Sending feedback
----------------

Does this look sane? Have we overlooked anything? Is there anything we
can do to make this more secure by default?

I'll read any replies here, or you can e-mail feedback to simon AT
simonwillison.net. Please say if you don't want stuff sent to that
address to be shared in public.

Thanks,

Simon Willison

--

You received this message because you are subscribed to the Google Groups 
"Django developers" group.
To post to this group, send email to django-develop...@googlegroups.com.
To unsubscribe from this group, send email to 
django-developers+unsubscr...@googlegroups.com.
For more options, visit this group at 
http://groups.google.com/group/django-developers?hl=en.


Reply via email to