X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~mdw/git/chopwood/blobdiff_plain/a2916c0635fec5b45ad742904db9f5769b48f53d..99968b29a36a958fa56cb2036f5f6ccee79fc2bd:/httpauth.py diff --git a/httpauth.py b/httpauth.py index 22648dd..7fdc687 100644 --- a/httpauth.py +++ b/httpauth.py @@ -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, '' % why)) + why = LOGIN_REASONS.get(why, '' % 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 --------------------------------------------------