chiark / gitweb /
httpauth.py: Improve the CSRF token stuff.
authorMark Wooding <mdw@distorted.org.uk>
Thu, 23 Jan 2014 19:08:02 +0000 (19:08 +0000)
committerMark Wooding <mdw@distorted.org.uk>
Thu, 23 Jan 2014 19:08:50 +0000 (19:08 +0000)
I used to use a simple XOR split, but while I was describing this
mitigation to someone else it struck me that it doesn't actually work:
the bad guy can accept a slowdown factor of 256 and guess corresponding
bytes of both halves to work through the whole token.

Replace the XOR split with a full-on all-or-nothing transform based on
OAEP.

httpauth.py

index 739d1df..383bdb1 100644 (file)
@@ -76,11 +76,19 @@ import util as U
 ### anyway isn't an interesting attack, but we must certainly require
 ### 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.)
+### include an appropriate hidden input element.
+###
+### The `%nonce' parameter encodes a randomized `all-or-nothing transform' of
+### the (deterministic) MAC tag on `chpwd-nonce.DATE.USER'.  The standard
+### advice for defeating the BREACH attack (which uses differential
+### compression of HTTP payloads which include attacker-provided data to
+### recover CSRF tokens) is to transmit an XOR-split of the token; but that
+### allows an adversary to recover the token two bytes at a time; this makes
+### the attack take 256 times longer, which doesn't really seem enough.  A
+### proper AONT, on the other hand, means that the adversary gets nothing if
+### he can't guess the entire transformed token -- and if he could do that,
+### he might as well just carry out the CSRF attack without messing with
+### BREACH in the first place.
 ###
 ### 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
@@ -179,10 +187,39 @@ 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 aont_step(x, y):
+  """Perform a step of the OAEP-based all-or-nothing transform."""
+  return xor_strings(y, CFG.AUTHHASH(x).digest())
+
+def aont_transform(m):
+  """
+  Apply an all-or-nothing transform to a (short, binary) message M.
+
+  The result is returned as a binary string.
+  """
+
+  ## The current all-or-nothing transform is basically OAEP: a two-round
+  ## Feistel network applied to a (possibly lopsided) block consisting of the
+  ## message and a random nonce.  Showing that this is an AONT (in the
+  ## random-oracle model) is pretty easy.
+  hashsz = CFG.AUTHHASH().digest_size
+  assert len(m) <= hashsz
+  r = OS.urandom(hashsz)
+  m = aont_step(r, m)
+  r = aont_step(m, r)
+  return r + m
+
+def aont_recover(c):
+  """
+  Recover a message from an all-or-nothing transform C (as a binary string).
+  """
+  hashsz = CFG.AUTHHASH().digest_size
+  if not (hashsz <= len(c) <= 2*hashsz):
+    raise AuthenticationFailed, 'BADNONCE'
+  r, m = c[:hashsz], c[hashsz:]
+  r = aont_step(m, r)
+  m = aont_step(r, m)
+  return m
 
 def mint_token(user):
   """Make and return a fresh token for USER."""
@@ -252,17 +289,11 @@ def check_auth(token, nonce = None):
 
   ## 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)
+    gtag = aont_recover(unhack_octets(nonce))
     if gtag != ntag: raise AuthenticationFailed, 'BADNONCE'
 
   ## Make a new nonce string for use in forms.
-  NONCE = mint_csrf_nonce(sec, ntag)
+  NONCE = hack_octets(aont_transform(ntag))
 
   ## Make sure the user still exists.
   try: acct = S.SERVICES['master'].find(user)