| 1 | ### -*-python-*- |
| 2 | ### |
| 3 | ### HTTP authentication |
| 4 | ### |
| 5 | ### (c) 2013 Mark Wooding |
| 6 | ### |
| 7 | |
| 8 | ###----- Licensing notice --------------------------------------------------- |
| 9 | ### |
| 10 | ### This file is part of Chopwood: a password-changing service. |
| 11 | ### |
| 12 | ### Chopwood is free software; you can redistribute it and/or modify |
| 13 | ### it under the terms of the GNU Affero General Public License as |
| 14 | ### published by the Free Software Foundation; either version 3 of the |
| 15 | ### License, or (at your option) any later version. |
| 16 | ### |
| 17 | ### Chopwood is distributed in the hope that it will be useful, |
| 18 | ### but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 19 | ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 20 | ### GNU Affero General Public License for more details. |
| 21 | ### |
| 22 | ### You should have received a copy of the GNU Affero General Public |
| 23 | ### License along with Chopwood; if not, see |
| 24 | ### <http://www.gnu.org/licenses/>. |
| 25 | |
| 26 | from __future__ import with_statement |
| 27 | |
| 28 | import base64 as BN |
| 29 | import hashlib as H |
| 30 | import hmac as HM |
| 31 | import itertools as I |
| 32 | import os as OS |
| 33 | |
| 34 | import cgi as CGI |
| 35 | import config as CONF; CFG = CONF.CFG |
| 36 | import dbmaint as D |
| 37 | import output as O; PRINT = O.PRINT |
| 38 | import service as S |
| 39 | import subcommand as SC |
| 40 | import util as U |
| 41 | |
| 42 | ###-------------------------------------------------------------------------- |
| 43 | ### About the authentication scheme. |
| 44 | ### |
| 45 | ### We mustn't allow a CGI user to make changes (or even learn about a user's |
| 46 | ### accounts) without authenticating first. Curently, that means a username |
| 47 | ### and password, though I really dislike this; maybe I'll add a feature for |
| 48 | ### handling TLS client certificates some time. |
| 49 | ### |
| 50 | ### We're particularly worried about cross-site request forgery: a forged |
| 51 | ### request to change a password to some known value lets a bad guy straight |
| 52 | ### into a restricted service -- and a change to the `master' account lets |
| 53 | ### him into all of them. |
| 54 | ### |
| 55 | ### Once we've satisfied ourselves of the user's credentials, we issue a |
| 56 | ### short-lived session token, stored in a cookie namde `chpwd-token'. This |
| 57 | ### token has the form `DATE.TAG.USER': here, DATE is the POSIX time of |
| 58 | ### issue, as a decimal number; USER is the user's login name; and TAG is a |
| 59 | ### cryptographic MAC tag on the string `chpwd-token.DATE.USER'. (The USER |
| 60 | ### name is on the end so that it can contain `.' characters without |
| 61 | ### introducing parsing difficulties.) |
| 62 | ### |
| 63 | ### Secrets for these MAC tags are stored in the database: secrets expire |
| 64 | ### after 30 minutes (invalidating all tokens issued with them); we only |
| 65 | ### issue a token with a secret that's at most five minutes old. A session's |
| 66 | ### lifetime, then, is somewhere between 25 and 30 minutes. We choose the |
| 67 | ### lower bound as the cookie lifetime, just so that error messages end up |
| 68 | ### consistent. |
| 69 | ### |
| 70 | ### A cookie with a valid token is sufficient to grant read-only access to a |
| 71 | ### user's account details. However, this authority is ambient: during the |
| 72 | ### validity period of the token, a cross-site request forgery can easily |
| 73 | ### succeed, since there's nothing about the rest of a request which is hard |
| 74 | ### to forge, and the cookie will be supplied automatically by the user |
| 75 | ### agent. Showing the user some information we were quite happy to release |
| 76 | ### anyway isn't an interesting attack, but we must certainly require |
| 77 | ### something stronger for state-change requests. Here, we also check a |
| 78 | ### special request parameter `%nonce': forms setting up a `POST' action must |
| 79 | ### include an appropriate hidden input element. |
| 80 | ### |
| 81 | ### The `%nonce' parameter encodes a randomized `all-or-nothing transform' of |
| 82 | ### the (deterministic) MAC tag on `chpwd-nonce.DATE.USER'. The standard |
| 83 | ### advice for defeating the BREACH attack (which uses differential |
| 84 | ### compression of HTTP payloads which include attacker-provided data to |
| 85 | ### recover CSRF tokens) is to transmit an XOR-split of the token; but that |
| 86 | ### allows an adversary to recover the token two bytes at a time; this makes |
| 87 | ### the attack take 256 times longer, which doesn't really seem enough. A |
| 88 | ### proper AONT, on the other hand, means that the adversary gets nothing if |
| 89 | ### he can't guess the entire transformed token -- and if he could do that, |
| 90 | ### he might as well just carry out the CSRF attack without messing with |
| 91 | ### BREACH in the first place. |
| 92 | ### |
| 93 | ### Messing about with cookies is a bit annoying, but it's hard to come up |
| 94 | ### with alternatives. I'm trying to keep the URLs fairly pretty, and anyway |
| 95 | ### putting secrets into them is asking for trouble, since user agents have |
| 96 | ### an awful tendecy to store URLs in a history database, send them to |
| 97 | ### motherships, leak them in `Referer' headers, and other awful things. Our |
| 98 | ### cookie is marked `HttpOnly' so, in particular, user agents must keep them |
| 99 | ### out of the grubby mitts of Javascript programs. |
| 100 | ### |
| 101 | ### I promise that I'm only using these cookies for the purposes of |
| 102 | ### maintaining security: I don't log them or do anything else at all with |
| 103 | ### them. |
| 104 | |
| 105 | ###-------------------------------------------------------------------------- |
| 106 | ### Generating and checking authentication tokens. |
| 107 | |
| 108 | ## Secret lifetime parameters. |
| 109 | CONF.DEFAULTS.update( |
| 110 | |
| 111 | ## The lifetime of a session cookie, in seconds. |
| 112 | SECRETLIFE = 30*60, |
| 113 | |
| 114 | ## Maximum age of an authentication key, in seconds. |
| 115 | SECRETFRESH = 5*60, |
| 116 | |
| 117 | ## Hash function to use for crypto. |
| 118 | AUTHHASH = H.sha256) |
| 119 | |
| 120 | def cleansecrets(): |
| 121 | """Remove dead secrets from the database.""" |
| 122 | with D.DB: |
| 123 | D.DB.execute("DELETE FROM secrets WHERE stamp < $stale", |
| 124 | stale = U.NOW - CFG.SECRETLIFE) |
| 125 | |
| 126 | def getsecret(when): |
| 127 | """ |
| 128 | Return the newest and most shiny secret no older than WHEN. |
| 129 | |
| 130 | If there is no such secret, or the only one available would have been stale |
| 131 | at WHEN, then return `None'. |
| 132 | """ |
| 133 | cleansecrets() |
| 134 | with D.DB: |
| 135 | D.DB.execute("""SELECT stamp, secret FROM secrets |
| 136 | WHERE stamp <= $when |
| 137 | ORDER BY stamp DESC""", |
| 138 | when = when) |
| 139 | row = D.DB.fetchone() |
| 140 | if row is None: return None |
| 141 | if row[0] < when - CFG.SECRETFRESH: return None |
| 142 | return row[1].decode('base64') |
| 143 | |
| 144 | def freshsecret(): |
| 145 | """Return a fresh secret.""" |
| 146 | cleansecrets() |
| 147 | with D.DB: |
| 148 | D.DB.execute("""SELECT secret FROM secrets |
| 149 | WHERE stamp >= $fresh |
| 150 | ORDER BY stamp DESC""", |
| 151 | fresh = U.NOW - CFG.SECRETFRESH) |
| 152 | row = D.DB.fetchone() |
| 153 | if row is not None: |
| 154 | sec = row[0].decode('base64') |
| 155 | else: |
| 156 | sec = OS.urandom(16) |
| 157 | D.DB.execute("""INSERT INTO secrets(stamp, secret) |
| 158 | VALUES ($stamp, $secret)""", |
| 159 | stamp = U.NOW, secret = sec.encode('base64')) |
| 160 | return sec |
| 161 | |
| 162 | def hack_octets(s): |
| 163 | """Return the octet string S, in a vaguely pretty form.""" |
| 164 | return BN.b64encode(s, '+$').rstrip('=') |
| 165 | |
| 166 | def unhack_octets(s): |
| 167 | """Reverse the operation done by `hack_octets'.""" |
| 168 | pad = (len(s) + 3)&3 - len(s) |
| 169 | try: |
| 170 | return BN.b64decode(s + '='*pad, '+$') |
| 171 | except TypeError: |
| 172 | raise AuthenticationFailed, 'BADNONCE' |
| 173 | |
| 174 | def auth_tag(sec, stamp, user): |
| 175 | """Compute a tag using secret SEC on `STAMP.USER'.""" |
| 176 | hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH) |
| 177 | hmac.update('chpwd-token.%d.%s' % (stamp, user)) |
| 178 | return hack_octets(hmac.digest()) |
| 179 | |
| 180 | def csrf_tag(sec, stamp, user): |
| 181 | """Compute a tag using secret SEC on `STAMP.USER'.""" |
| 182 | hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH) |
| 183 | hmac.update('chpwd-nonce.%d.%s' % (stamp, user)) |
| 184 | return hmac.digest() |
| 185 | |
| 186 | def xor_strings(x, y): |
| 187 | """Return the bitwise XOR of two octet strings.""" |
| 188 | return ''.join(chr(ord(xc) ^ ord(yc)) for xc, yc in I.izip(x, y)) |
| 189 | |
| 190 | def aont_step(x, y): |
| 191 | """Perform a step of the OAEP-based all-or-nothing transform.""" |
| 192 | return xor_strings(y, CFG.AUTHHASH(x).digest()) |
| 193 | |
| 194 | def aont_transform(m): |
| 195 | """ |
| 196 | Apply an all-or-nothing transform to a (short, binary) message M. |
| 197 | |
| 198 | The result is returned as a binary string. |
| 199 | """ |
| 200 | |
| 201 | ## The current all-or-nothing transform is basically OAEP: a two-round |
| 202 | ## Feistel network applied to a (possibly lopsided) block consisting of the |
| 203 | ## message and a random nonce. Showing that this is an AONT (in the |
| 204 | ## random-oracle model) is pretty easy. |
| 205 | hashsz = CFG.AUTHHASH().digest_size |
| 206 | assert len(m) <= hashsz |
| 207 | r = OS.urandom(hashsz) |
| 208 | m = aont_step(r, m) |
| 209 | r = aont_step(m, r) |
| 210 | return r + m |
| 211 | |
| 212 | def aont_recover(c): |
| 213 | """ |
| 214 | Recover a message from an all-or-nothing transform C (as a binary string). |
| 215 | """ |
| 216 | hashsz = CFG.AUTHHASH().digest_size |
| 217 | if not (hashsz <= len(c) <= 2*hashsz): |
| 218 | raise AuthenticationFailed, 'BADNONCE' |
| 219 | r, m = c[:hashsz], c[hashsz:] |
| 220 | r = aont_step(m, r) |
| 221 | m = aont_step(r, m) |
| 222 | return m |
| 223 | |
| 224 | def mint_token(user): |
| 225 | """Make and return a fresh token for USER.""" |
| 226 | sec = freshsecret() |
| 227 | tag = auth_tag(sec, U.NOW, user) |
| 228 | return '%d.%s.%s' % (U.NOW, tag, user) |
| 229 | |
| 230 | ## Long messages for reasons why one might have been redirected back to the |
| 231 | ## login page. |
| 232 | LOGIN_REASONS = { |
| 233 | 'AUTHFAIL': 'incorrect user name or password', |
| 234 | 'NOAUTH': 'not authenticated', |
| 235 | 'NONONCE': 'missing nonce', |
| 236 | 'BADTOKEN': 'malformed token', |
| 237 | 'BADTIME': 'invalid timestamp', |
| 238 | 'BADNONCE': 'nonce mismatch', |
| 239 | 'EXPIRED': 'session timed out', |
| 240 | 'BADTAG': 'incorrect tag', |
| 241 | 'NOUSER': 'unknown user name', |
| 242 | 'LOGOUT': 'explicitly logged out', |
| 243 | None: None |
| 244 | } |
| 245 | |
| 246 | class AuthenticationFailed (U.ExpectedError): |
| 247 | """ |
| 248 | An authentication error. The most interesting extra feature is an |
| 249 | attribute `why' carrying a reason code, which can be looked up in |
| 250 | `LOGIN_REASONS'. |
| 251 | """ |
| 252 | def __init__(me, why): |
| 253 | msg = LOGIN_REASONS[why] |
| 254 | U.ExpectedError.__init__(me, 403, msg) |
| 255 | me.why = why |
| 256 | |
| 257 | def check_auth(token, nonce = None): |
| 258 | """ |
| 259 | Check that the TOKEN is valid, comparing it against the NONCE if this is |
| 260 | not `None'. |
| 261 | |
| 262 | If the token is OK, then return the correct user name, and set `NONCE' to a |
| 263 | new nonce for the next request. Otherwise raise an `AuthenticationFailed' |
| 264 | exception with an appropriate `why'. |
| 265 | """ |
| 266 | |
| 267 | global NONCE |
| 268 | |
| 269 | ## If the token has been explicitly clobbered, then we're logged out. |
| 270 | if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT' |
| 271 | |
| 272 | ## Parse the token. |
| 273 | bits = token.split('.', 3) |
| 274 | if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN' |
| 275 | stamp, tag, user = bits |
| 276 | |
| 277 | ## Check the stamp, and find the right secret. |
| 278 | if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME' |
| 279 | when = int(stamp) |
| 280 | sec = getsecret(when) |
| 281 | if sec is None: raise AuthenticationFailed, 'EXPIRED' |
| 282 | |
| 283 | ## Check the tag. |
| 284 | t = auth_tag(sec, when, user) |
| 285 | if t != tag: raise AuthenticationFailed, 'BADTAG' |
| 286 | |
| 287 | ## Determine the correct CSRF tag. |
| 288 | ntag = csrf_tag(sec, when, user) |
| 289 | |
| 290 | ## Check that the nonce matches, if one was supplied. |
| 291 | if nonce is not None: |
| 292 | gtag = aont_recover(unhack_octets(nonce)) |
| 293 | if gtag != ntag: raise AuthenticationFailed, 'BADNONCE' |
| 294 | |
| 295 | ## Make a new nonce string for use in forms. |
| 296 | NONCE = hack_octets(aont_transform(ntag)) |
| 297 | |
| 298 | ## Make sure the user still exists. |
| 299 | try: acct = S.SERVICES['master'].find(user) |
| 300 | except S.UnknownUser: raise AuthenticationFailed, 'NOUSER' |
| 301 | |
| 302 | ## Done. |
| 303 | return user |
| 304 | |
| 305 | def bake_cookie(value): |
| 306 | """ |
| 307 | Return a properly baked authentication-token cookie with the given VALUE. |
| 308 | """ |
| 309 | return CGI.cookie('chpwd-token', value, |
| 310 | httponly = True, |
| 311 | secure = CGI.SSLP, |
| 312 | path = CFG.SCRIPT_NAME, |
| 313 | max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH)) |
| 314 | |
| 315 | ###-------------------------------------------------------------------------- |
| 316 | ### Authentication commands. |
| 317 | |
| 318 | ## A dummy string, for when we're invoked from the command-line. |
| 319 | NONCE = '@DUMMY-NONCE' |
| 320 | |
| 321 | @CGI.subcommand( |
| 322 | 'login', ['cgi-noauth'], |
| 323 | 'Authenticate to the CGI machinery', |
| 324 | opts = [SC.Opt('why', '-w', '--why', |
| 325 | 'Reason for redirection back to the login page.', |
| 326 | argname = 'WHY')]) |
| 327 | def cmd_login(why = None): |
| 328 | CGI.page('login.fhtml', |
| 329 | title = 'Chopwood: login', |
| 330 | why = LOGIN_REASONS.get(why, '<unknown error %s>' % why)) |
| 331 | |
| 332 | @CGI.subcommand( |
| 333 | 'auth', ['cgi-noauth'], |
| 334 | 'Verify a user name and password', |
| 335 | methods = ['POST'], |
| 336 | params = [SC.Arg('u'), SC.Arg('pw')]) |
| 337 | def cmd_auth(u, pw): |
| 338 | svc = S.SERVICES['master'] |
| 339 | try: |
| 340 | acct = svc.find(u) |
| 341 | acct.check(pw) |
| 342 | except (S.UnknownUser, S.IncorrectPassword): |
| 343 | CGI.redirect(CGI.action('login', why = 'AUTHFAIL')) |
| 344 | else: |
| 345 | t = mint_token(u) |
| 346 | CGI.redirect(CGI.action('list', u), |
| 347 | set_cookie = bake_cookie(t)) |
| 348 | |
| 349 | ###----- That's all, folks -------------------------------------------------- |