| 6 | |
| 7 | == Justification == |
| 8 | |
| 9 | * Signing is a widely used web application security technique. Django uses it in a few places already (the form wizard and sessions contrib apps), and it is useful any time an application might want to pass data through an untrusted channel and ensure it hasn't been tampered on the other side. The Web is generally an untrusted channel. |
| 10 | * Signed cookies can replace sessions in many use cases, with the significant bonus that unlike sessions they do not require a round-trip to a persistent store. |
| 11 | * Signing is hard to do right, and most hand-rolled implementations make similar mistakes. Signing is best handled with hmac and sha1, but Django implementations currently tend to use the weaker MD5 without hmac. A signing implementation in Django core could be audited by expert cryptographers, providing a secure base on which other Django applications could build. |
| 12 | |
| 13 | Potential uses for signing: |
| 14 | |
| 15 | * Signed cookies |
| 16 | * Generating CSRF tokens |
| 17 | * Secure /logout/ and /change-language/ links |
| 18 | * Securing /login/?next=/some/path/ |
| 19 | * Securing hidden fields in form wizards |
| 20 | * Recover-your-account links in e-mails |
| 21 | |
| 22 | == Signed cookie API == |
| 23 | |
| 24 | This is under heavy discussion on the mailing list. Current proposals for setting a signed cookie: |
| 25 | |
| 26 | {{{ |
| 27 | response.set_cookie(key, value, signed=True) |
| 28 | }}} |
| 29 | Or |
| 30 | {{{ |
| 31 | response.set_signed_cookie(key, value) |
| 32 | }}} |
| 33 | |
| 34 | (Blanket signing everything is probably not an option as some cookies, such as those used by Google Analytics, need to remain unsigned) |
| 35 | |
| 36 | Reading a signed cookie is harder. Since cookies may be both signed and unsigned, it's not going to be possible to transparently verify signed cookies and return them using the same API as unsigned cookies. Benjamin Slavin pointed out that an attacker could then set bad data in an unsigned cookie and the auto-unsigning code would assume it was never signed in the first place. This means we need an explicit API for reading cookies that should have been signed. |
| 37 | |
| 38 | Current suggestions include: |
| 39 | |
| 40 | {{{ |
| 41 | request.unsign_cookie(key) # reflects the lower-level API, but unintuitive |
| 42 | request.get_signed_cookie(key) # quite verbose |
| 43 | request.COOKIES.get_signed(key) # similar to request.POST.get_list |
| 44 | request.COOKIES.get_unsigned(key) |
| 45 | }}} |
| 46 | |
| 47 | == Proposed signing API == |
| 48 | |
| 49 | Simon Willison has offered to donate the signing code from his django-openid library for the low level signing API: |
| 50 | |
| 51 | http://github.com/simonw/django-openid/blob/master/django_openid/signed.py |
| 52 | |
| 53 | http://github.com/simonw/django-openid/blob/master/django_openid/tests/signing_tests.py |
| 54 | |
| 55 | The API would look like this: |
| 56 | |
| 57 | {{{ |
| 58 | >>> from django.utils import signed |
| 59 | >>> signed.sign('hello') |
| 60 | 'hello.9asVJn9dfv6qLJ_BYObzF7mmH8c' |
| 61 | }}} |
| 62 | |
| 63 | The signature is a URL-safe base64 encoded digest of the hmac/sha1. I used base64 rather than .hexdigest() for space reasons - base64 digests are 27 characters, hexadecimal digests are 40. When you're including signatures in cookies and URLs (especially account recovery URLs sent out in plain text, 80 character wide e-mails) every byte counts. |
| 64 | |
| 65 | {{{ |
| 66 | >>> signed.unsign('hello.9asVJn9dfv6qLJ_BYObzF7mmH8c') |
| 67 | 'hello' |
| 68 | >>> signed.unsign('hello.badsignature') |
| 69 | Traceback (most recent call last): |
| 70 | ... |
| 71 | BadSignature: Signature failed: badsignature |
| 72 | }}} |
| 73 | |
| 74 | !BadSignature would probably be a subclass of django.core.exceptions.!SuspiciousOperation |
| 75 | |
| 76 | {{{ |
| 77 | >>> signed.dumps({"a": "foo"}) |
| 78 | 'KGRwMApTJ2EnCnAxClMnZm9vJwpwMgpzLg.mYepoYkzWwXRmsCTVJm3Mb0HHz4' |
| 79 | >>> signed.loads(_) |
| 80 | {'a': 'foo'} |
| 81 | }}} |
| 82 | |
| 83 | Again, the pickle is URL-safe base64 encoded to take up less valuable cookie space and generally make it easier to pass around on the Web. A nice thing about URL-safe base64 is that it uses 64 out of the 65 URL-safe characters (by URL-safe I mean characters that are left unchanged by Python's urllib.urlencode function) - the remaining character is the period, which I use to separate the pickle from the signature. |
| 84 | |
| 85 | signed.dumps takes a couple of extra optional arguments. The first is compress=True (default is False) which zlib compresses the pickle if doing so will save any space: |
| 86 | |
| 87 | {{{ |
| 88 | >>> import this # to get an object worth compressing |
| 89 | ... |
| 90 | >>> len(signed.dumps(this.s)) |
| 91 | 1207 |
| 92 | >>> len(signed.dumps(this.s, compress=True)) |
| 93 | 637 |
| 94 | }}} |
| 95 | |
| 96 | By default, all signatures use Django's SECRET_KEY. If you want to sign with a different key, you can pass it as an argument to the various functions: |
| 97 | |
| 98 | {{{ |
| 99 | >>> signed.sign('hello', key='sekrit') |
| 100 | 'hello.o6MKehoOfZ2b2FU84wzibW6IWxI' |
| 101 | >>> signed.unsign(_, key='sekrit') |
| 102 | 'hello' |
| 103 | }}} |
| 104 | |
| 105 | The dumps and loads methods also take a key argument, as well as an additional optional extra_key argument for if you want to generate different signatures for different parts within your application (useful for the extra paranoid): |
| 106 | |
| 107 | {{{ |
| 108 | >>> signed.dumps('hello', key='sekrit', extra_key='ultra') |
| 109 | 'UydoZWxsbycKcDAKLg.1XYDpILo5xqSwImfa3WuJJT4RPo' |
| 110 | >>> signed.loads(_, key='sekrit', extra_key='ultra') |
| 111 | 'hello' |
| 112 | }}} |
| 113 | |
| 114 | === Potential additions to the above API === |
| 115 | |
| 116 | * Low level access to just the signature generation routine itself (at the moment sign() and unsign() append the signature to the string themselves) |
| 117 | * Maybe functions or parameters for creating timestamped signatures and failing the signature if it is older than a certain expiry timeout. |
| 118 | |
| 119 | == SECRET_KEY considerations == |
| 120 | |
| 121 | Simon says: |
| 122 | |
| 123 | > One thing that worries me slightly about increasing the amount of signing going on in Django is that it elevates the importance of the SECRET_KEY. I'm currently ignorant of best practices regarding protecting this kind of shared secret, but the steps we take (***ing it out from the debug pages and otherwise ignoring it) could almost certainly be improved. |
| 124 | |
| 125 | > One thing that's particularly interesting to me is what happens when you change your secret. If you're changing your secret because it's leaked then obviously you want stuff signed with the old secret to become invalid immediately, but I can imagine some users wanting to rotate their secret keys on a continual basis for added security against brute force attacks. |
| 126 | |
| 127 | > If you're rotating your secret, invalidating all of your users signed cookies etc is a bit of an annoyance. It might be worth supporting two secrets - the current SECRET_KEY and an optional OLD_SECRET_KEY - with unsigning operations falling back on the old key if the current key fails. This would allow users to deploy a new secret while keeping the old one valid for a week or so, upgrading any tokens that use the old key in the process. Amazon recently announced a similar feature for handling web service credentials, which inspired this suggestion: |
| 128 | |
| 129 | > http://aws.typepad.com/aws/2009/09/aws-access-credential-rotation.html |
| 130 | |
| 131 | > This is probably all too much complication, but it's something that's been nagging at me since I started increasing my dependence on the SECRET_KEY setting. |