X-Git-Url: https://git.distorted.org.uk/~mdw/chopwood/blobdiff_plain/7d41b86a3d323404887ee58eb67a97c66fdf870b..HEAD:/httpauth.py diff --git a/httpauth.py b/httpauth.py index 31e4ca1..0fdad76 100644 --- a/httpauth.py +++ b/httpauth.py @@ -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.""" @@ -190,16 +230,16 @@ def mint_token(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 } @@ -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) @@ -298,6 +332,7 @@ def cmd_login(why = None): @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']