import base64 as BN
import hashlib as H
import hmac as HM
+import itertools as I
import os as OS
import cgi as CGI
###
### 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
### 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
"""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.
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
## 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'
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'
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']