chiark / gitweb /
service.py: Add missing `_describe' method for CommandRemoteService.
[chopwood] / httpauth.py
index 22648dd4c09be34809b784fe4e52ec7e780a0bc8..7fdc687441d1f28498d0173b23c38cb86efe7f4e 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
@@ -100,7 +112,10 @@ CONF.DEFAULTS.update(
   SECRETLIFE = 30*60,
 
   ## Maximum age of an authentication key, in seconds.
-  SECRETFRESH = 5*60)
+  SECRETFRESH = 5*60,
+
+  ## Hash function to use for crypto.
+  AUTHHASH = H.sha256)
 
 def cleansecrets():
   """Remove dead secrets from the database."""
@@ -146,22 +161,71 @@ def freshsecret():
 
 def hack_octets(s):
   """Return the octet string S, in a vaguely pretty form."""
-  return BN.b64encode(s) \
-           .rstrip('=') \
-           .replace('/', '$')
-
-def auth_tag(sec, stamp, nonce, user):
-  """Compute a tag using secret SEC on `STAMP.NONCE.USER'."""
-  hmac = HM.HMAC(sec, digestmod = H.sha256)
-  hmac.update('%d.%s.%s' % (stamp, nonce, user))
+  return BN.b64encode(s, '+$').rstrip('=')
+
+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('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.
@@ -175,6 +239,7 @@ LOGIN_REASONS = {
   'EXPIRED': 'session timed out',
   'BADTAG': 'incorrect tag',
   'NOUSER': 'unknown user name',
+  'LOGOUT': 'explicitly logged out',
   None: None
 }
 
@@ -194,21 +259,20 @@ 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
 
+  ## If the token has been explicitly clobbered, then we're logged out.
+  if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
+
   ## 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'
@@ -217,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'
@@ -227,6 +302,16 @@ def check_auth(token, nonce = None):
   ## Done.
   return user
 
+def bake_cookie(value):
+  """
+  Return a properly baked authentication-token cookie with the given VALUE.
+  """
+  return CGI.cookie('chpwd-token', value,
+                    httponly = True,
+                    secure = CGI.SSLP,
+                    path = CFG.SCRIPT_NAME,
+                    max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH))
+
 ###--------------------------------------------------------------------------
 ### Authentication commands.
 
@@ -242,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']
@@ -257,11 +343,7 @@ def cmd_auth(u, pw):
     CGI.redirect(CGI.action('login', why = 'AUTHFAIL'))
   else:
     t = mint_token(u)
-    CGI.redirect(CGI.action('list'),
-                 set_cookie = CGI.cookie('chpwd-token', t,
-                                         httponly = True,
-                                         path = CFG.SCRIPT_NAME,
-                                         max_age = (CFG.SECRETLIFE -
-                                                    CFG.SECRETFRESH)))
+    CGI.redirect(CGI.action('list', u),
+                 set_cookie = bake_cookie(t))
 
 ###----- That's all, folks --------------------------------------------------