Commit | Line | Data |
---|---|---|
a2916c06 MW |
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 | |
3cf8e1b7 | 31 | import itertools as I |
a2916c06 MW |
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 | |
3cf8e1b7 MW |
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.) | |
a2916c06 MW |
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 | |
3cf8e1b7 MW |
77 | ### something stronger for state-change requests. Here, we also check a |
78 | ### special request parameter `%nonce': forms setting up a `POST' action must | |
7405b0d4 MW |
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. | |
a2916c06 MW |
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. | |
44e94112 MW |
115 | SECRETFRESH = 5*60, |
116 | ||
117 | ## Hash function to use for crypto. | |
118 | AUTHHASH = H.sha256) | |
a2916c06 MW |
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.""" | |
40c5485b | 164 | return BN.b64encode(s, '+$').rstrip('=') |
a2916c06 | 165 | |
3cf8e1b7 MW |
166 | def unhack_octets(s): |
167 | """Reverse the operation done by `hack_octets'.""" | |
168 | pad = (len(s) + 3)&3 - len(s) | |
558d2d93 MW |
169 | try: |
170 | return BN.b64decode(s + '='*pad, '+$') | |
171 | except TypeError: | |
172 | raise AuthenticationFailed, 'BADNONCE' | |
3cf8e1b7 MW |
173 | |
174 | def auth_tag(sec, stamp, user): | |
175 | """Compute a tag using secret SEC on `STAMP.USER'.""" | |
44e94112 | 176 | hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH) |
3cf8e1b7 | 177 | hmac.update('chpwd-token.%d.%s' % (stamp, user)) |
a2916c06 MW |
178 | return hack_octets(hmac.digest()) |
179 | ||
3cf8e1b7 MW |
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 | ||
7405b0d4 MW |
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 | |
3cf8e1b7 | 223 | |
a2916c06 MW |
224 | def mint_token(user): |
225 | """Make and return a fresh token for USER.""" | |
226 | sec = freshsecret() | |
3cf8e1b7 MW |
227 | tag = auth_tag(sec, U.NOW, user) |
228 | return '%d.%s.%s' % (U.NOW, tag, user) | |
a2916c06 MW |
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', | |
170f1769 | 242 | 'LOGOUT': 'explicitly logged out', |
a2916c06 MW |
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 | ||
3cf8e1b7 MW |
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'. | |
a2916c06 MW |
265 | """ |
266 | ||
267 | global NONCE | |
268 | ||
170f1769 MW |
269 | ## If the token has been explicitly clobbered, then we're logged out. |
270 | if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT' | |
271 | ||
a2916c06 MW |
272 | ## Parse the token. |
273 | bits = token.split('.', 3) | |
3cf8e1b7 MW |
274 | if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN' |
275 | stamp, tag, user = bits | |
a2916c06 MW |
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. | |
3cf8e1b7 | 284 | t = auth_tag(sec, when, user) |
a2916c06 MW |
285 | if t != tag: raise AuthenticationFailed, 'BADTAG' |
286 | ||
3cf8e1b7 MW |
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: | |
7405b0d4 | 292 | gtag = aont_recover(unhack_octets(nonce)) |
3cf8e1b7 MW |
293 | if gtag != ntag: raise AuthenticationFailed, 'BADNONCE' |
294 | ||
295 | ## Make a new nonce string for use in forms. | |
7405b0d4 | 296 | NONCE = hack_octets(aont_transform(ntag)) |
3cf8e1b7 | 297 | |
a2916c06 MW |
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 | ||
60b6f5b3 MW |
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 | ||
a2916c06 MW |
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', | |
7d41b86a | 330 | why = LOGIN_REASONS.get(why, '<unknown error %s>' % why)) |
a2916c06 MW |
331 | |
332 | @CGI.subcommand( | |
333 | 'auth', ['cgi-noauth'], | |
334 | 'Verify a user name and password', | |
335 | params = [SC.Arg('u'), SC.Arg('pw')]) | |
336 | def cmd_auth(u, pw): | |
337 | svc = S.SERVICES['master'] | |
338 | try: | |
339 | acct = svc.find(u) | |
340 | acct.check(pw) | |
341 | except (S.UnknownUser, S.IncorrectPassword): | |
342 | CGI.redirect(CGI.action('login', why = 'AUTHFAIL')) | |
343 | else: | |
344 | t = mint_token(u) | |
bb623e8f | 345 | CGI.redirect(CGI.action('list', u), |
60b6f5b3 | 346 | set_cookie = bake_cookie(t)) |
a2916c06 MW |
347 | |
348 | ###----- That's all, folks -------------------------------------------------- |