chiark / gitweb /
httpauth.py: Capitalize the login whinges.
[chopwood] / httpauth.py
index 6bb3ec83c14701433eeb6a83801ecf8a2000895f..0fdad7666346e82e83407b492cf929d5a8905000 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,21 @@ 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 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
@@ -151,32 +163,83 @@ 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)
+  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'."""
   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 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."""
   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.
 LOGIN_REASONS = {
-  'AUTHFAIL': 'incorrect user name or password',
-  'NOAUTH': 'not authenticated',
-  'NONONCE': 'missing nonce',
-  'BADTOKEN': 'malformed token',
-  'BADTIME': 'invalid timestamp',
-  'BADNONCE': 'nonce mismatch',
-  'EXPIRED': 'session timed out',
-  'BADTAG': 'incorrect tag',
-  'NOUSER': 'unknown user name',
-  'LOGOUT': 'explicitly logged out',
+  'AUTHFAIL': 'Incorrect user name or password',
+  'NOAUTH': 'Not authenticated',
+  'NONONCE': 'Missing nonce',
+  'BADTOKEN': 'Malformed token',
+  'BADTIME': 'Invalid timestamp',
+  'BADNONCE': 'Nonce mismatch',
+  'EXPIRED': 'Session timed out',
+  'BADTAG': 'Incorrect tag',
+  'NOUSER': 'Unknown user name',
+  'LOGOUT': 'Explicitly logged out',
   None: None
 }
 
@@ -196,9 +259,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 +271,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 +281,20 @@ 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:
+    gtag = aont_recover(unhack_octets(nonce))
+    if gtag != ntag: raise AuthenticationFailed, 'BADNONCE'
+
+  ## Make a new nonce string for use in forms.
+  NONCE = hack_octets(aont_transform(ntag))
+
   ## Make sure the user still exists.
   try: acct = S.SERVICES['master'].find(user)
   except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'
@@ -257,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']