chiark / gitweb /
service.py: Add missing `_describe' method for CommandRemoteService.
[chopwood] / httpauth.py
index abde98b2a6fd3899fd833b32a9b076e66afdebe0..7fdc687441d1f28498d0173b23c38cb86efe7f4e 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
@@ -158,7 +166,10 @@ def hack_octets(s):
 def unhack_octets(s):
   """Reverse the operation done by `hack_octets'."""
   pad = (len(s) + 3)&3 - len(s)
-  return BN.b64decode(s + '='*pad, '+$')
+  try:
+    return BN.b64decode(s + '='*pad, '+$')
+  except TypeError:
+    raise AuthenticationFailed, 'BADNONCE'
 
 def auth_tag(sec, stamp, user):
   """Compute a tag using secret SEC on `STAMP.USER'."""
@@ -176,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."""
@@ -249,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)
@@ -293,11 +327,12 @@ NONCE = '@DUMMY-NONCE'
 def cmd_login(why = None):
   CGI.page('login.fhtml',
            title = 'Chopwood: login',
-           why =LOGIN_REASONS.get(why, '<unknown error %s>' % why))
+           why = LOGIN_REASONS.get(why, '<unknown error %s>' % why))
 
 @CGI.subcommand(
   'auth', ['cgi-noauth'],
   'Verify a user name and password',
+  methods = ['POST'],
   params = [SC.Arg('u'), SC.Arg('pw')])
 def cmd_auth(u, pw):
   svc = S.SERVICES['master']