chiark / gitweb /
httpauth.py, cookies.fhtml: Randomize CSRF token to prevent BREACH.
[chopwood] / httpauth.py
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'