chiark / gitweb /
httpauth.py, cookies.fhtml: Randomize CSRF token to prevent BREACH.
authorMark Wooding <mdw@distorted.org.uk>
Sat, 10 Aug 2013 12:31:30 +0000 (13:31 +0100)
committerMark Wooding <mdw@distorted.org.uk>
Sat, 10 Aug 2013 12:33:27 +0000 (13:33 +0100)
The use of `gzip' compression by servers, combined with the possibility
of inserting request parameters in responses can leak information from
responses, notably the CSRF token.  We can defend this by splitting it
into two XOR pieces and combining them together again in the server.

cookies.fhtml
httpauth.py

index f8862b33d9e68adef882d5b0d77160516d5cd800..58b725e9a5be5a5cded48f6a4767d88147c42db1 100644 (file)
@@ -59,9 +59,9 @@ message whenever you hit the reload button.
 <p>If you actually look at the cookie, you find that it looks something like
 this:
 <blockquote>
-  <tt>1357322139.HFsD16dOh1jjdhXdO%24gkjQ.eBcBNYFhi6sKpGuahfr7yQDzqOJuYZZexJbVug9ultU.mdw</tt>
+  <tt>1357322139.eBcBNYFhi6sKpGuahfr7yQDzqOJuYZZexJbVug9ultU.mdw</tt>
 </blockquote>
-(Did I say something about long and ugly?)  It consists of four pieces
+(Did I say something about long and ugly?)  It consists of three pieces
 separated by dots &lsquo;<tt>.</tt>&rsquo;.
 
 <dl>
@@ -70,13 +70,6 @@ separated by dots &lsquo;<tt>.</tt>&rsquo;.
 seconds since 1974&ndash;01&ndash;01 00:00:00 UTC (or what would have been
 that if UTC had existed back then in its current form).
 
-<dt>Nonce
-<dd>This is just a random string.  When you change a password, the server
-checks that the request includes a copy of this nonce, as a protection
-against
-<a href="http://en.wikipedia.org/wiki/Cross-site_request_forgery"><em>cross-site
-request forgery</em></a> attacks.
-
 <dt>Tag
 <dd>This is a cryptographic check that the other parts of the token
 haven&rsquo;t been modfied by an attacker.
index 6bb3ec83c14701433eeb6a83801ecf8a2000895f..abde98b2a6fd3899fd833b32a9b076e66afdebe0 100644 (file)
@@ -28,6 +28,7 @@ from __future__ import with_statement
 import base64 as BN
 import hashlib as H
 import hmac as HM
+import itertools as I
 import os as OS
 
 import cgi as CGI
@@ -53,11 +54,11 @@ import util as U
 ###
 ### Once we've satisfied ourselves of the user's credentials, we issue a
 ### short-lived session token, stored in a cookie namde `chpwd-token'.  This
-### token has the form `DATE.NONCE.TAG.USER': here, DATE is the POSIX time of
-### issue, as a decimal number; NONCE is a randomly chosen string, encoded in
-### base64, USER is the user's login name, and TAG is a cryptographic MAC tag
-### on the string `DATE.NONCE.USER'.  (The USER name is on the end so that it
-### can contain `.' characters without introducing parsing difficulties.)
+### token has the form `DATE.TAG.USER': here, DATE is the POSIX time of
+### issue, as a decimal number; USER is the user's login name; and TAG is a
+### cryptographic MAC tag on the string `chpwd-token.DATE.USER'.  (The USER
+### name is on the end so that it can contain `.' characters without
+### introducing parsing difficulties.)
 ###
 ### Secrets for these MAC tags are stored in the database: secrets expire
 ### after 30 minutes (invalidating all tokens issued with them); we only
@@ -73,10 +74,13 @@ import util as U
 ### to forge, and the cookie will be supplied automatically by the user
 ### agent.  Showing the user some information we were quite happy to release
 ### anyway isn't an interesting attack, but we must certainly require
-### something stronger for state-change requests.  Here, we also check that a
-### special request parameter `%nonce' matches the token's NONCE field: forms
-### setting up a `POST' action must include an appropriate hidden input
-### element.
+### something stronger for state-change requests.  Here, we also check a
+### special request parameter `%nonce': forms setting up a `POST' action must
+### include an appropriate hidden input element.  The `%nonce' parameter has
+### the form `LEFT.RIGHT', where LEFT and RIGHT are two base-64 strings such
+### that their XOR is the (deterministic) MAC tag on `chpwd-nonce.DATE.USER'.
+### (The LEFT string is chosen at random, and the RIGHT string is set to the
+### appropriate TAG XOR LEFT.)
 ###
 ### Messing about with cookies is a bit annoying, but it's hard to come up
 ### with alternatives.  I'm trying to keep the URLs fairly pretty, and anyway
@@ -151,18 +155,37 @@ def hack_octets(s):
   """Return the octet string S, in a vaguely pretty form."""
   return BN.b64encode(s, '+$').rstrip('=')
 
-def auth_tag(sec, stamp, nonce, user):
-  """Compute a tag using secret SEC on `STAMP.NONCE.USER'."""
+def unhack_octets(s):
+  """Reverse the operation done by `hack_octets'."""
+  pad = (len(s) + 3)&3 - len(s)
+  return BN.b64decode(s + '='*pad, '+$')
+
+def auth_tag(sec, stamp, user):
+  """Compute a tag using secret SEC on `STAMP.USER'."""
   hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
-  hmac.update('%d.%s.%s' % (stamp, nonce, user))
+  hmac.update('chpwd-token.%d.%s' % (stamp, user))
   return hack_octets(hmac.digest())
 
+def csrf_tag(sec, stamp, user):
+  """Compute a tag using secret SEC on `STAMP.USER'."""
+  hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
+  hmac.update('chpwd-nonce.%d.%s' % (stamp, user))
+  return hmac.digest()
+
+def xor_strings(x, y):
+  """Return the bitwise XOR of two octet strings."""
+  return ''.join(chr(ord(xc) ^ ord(yc)) for xc, yc in I.izip(x, y))
+
+def mint_csrf_nonce(sec, ntag):
+  left = OS.urandom(len(ntag))
+  right = xor_strings(left, ntag)
+  return '%s.%s' % (hack_octets(left), hack_octets(right))
+
 def mint_token(user):
   """Make and return a fresh token for USER."""
   sec = freshsecret()
-  nonce = hack_octets(OS.urandom(16))
-  tag = auth_tag(sec, U.NOW, nonce, user)
-  return '%d.%s.%s.%s' % (U.NOW, nonce, tag, user)
+  tag = auth_tag(sec, U.NOW, user)
+  return '%d.%s.%s' % (U.NOW, tag, user)
 
 ## Long messages for reasons why one might have been redirected back to the
 ## login page.
@@ -196,9 +219,9 @@ def check_auth(token, nonce = None):
   Check that the TOKEN is valid, comparing it against the NONCE if this is
   not `None'.
 
-  If the token is OK, then return the correct user name, and set `NONCE' set
-  to the appropriate portion of the token.  Otherwise raise an
-  `AuthenticationFailed' exception with an appropriate `why'.
+  If the token is OK, then return the correct user name, and set `NONCE' to a
+  new nonce for the next request.  Otherwise raise an `AuthenticationFailed'
+  exception with an appropriate `why'.
   """
 
   global NONCE
@@ -208,12 +231,8 @@ def check_auth(token, nonce = None):
 
   ## Parse the token.
   bits = token.split('.', 3)
-  if len(bits) != 4: raise AuthenticationFailed, 'BADTOKEN'
-  stamp, NONCE, tag, user = bits
-
-  ## Check that the nonce matches, if one was supplied.
-  if nonce is not None and nonce != NONCE:
-    raise AuthenticationFailed, 'BADNONCE'
+  if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN'
+  stamp, tag, user = bits
 
   ## Check the stamp, and find the right secret.
   if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME'
@@ -222,9 +241,26 @@ def check_auth(token, nonce = None):
   if sec is None: raise AuthenticationFailed, 'EXPIRED'
 
   ## Check the tag.
-  t = auth_tag(sec, when, NONCE, user)
+  t = auth_tag(sec, when, user)
   if t != tag: raise AuthenticationFailed, 'BADTAG'
 
+  ## Determine the correct CSRF tag.
+  ntag = csrf_tag(sec, when, user)
+
+  ## Check that the nonce matches, if one was supplied.
+  if nonce is not None:
+    bits = nonce.split('.', 2)
+    if len(bits) != 2: raise AuthenticationFailed, 'BADNONCE'
+    try: left, right = map(unhack_octets, bits)
+    except TypeError: raise AuthenticationFailed, 'BADNONCE'
+    if len(left) != len(right) or len(left) != len(ntag):
+      raise AuthenticationFailed, 'BADNONCE'
+    gtag = xor_strings(left, right)
+    if gtag != ntag: raise AuthenticationFailed, 'BADNONCE'
+
+  ## Make a new nonce string for use in forms.
+  NONCE = mint_csrf_nonce(sec, ntag)
+
   ## Make sure the user still exists.
   try: acct = S.SERVICES['master'].find(user)
   except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'