1 """
2 This module contains an C{AuthDigestMiddleware} for authenticating HTTP
3 requests using the method described in RFC2617. The credentials are to be
4 provided using an C{AuthTokenGenerator} or a compatible instance. Furthermore
5 digest authentication has to preserve some state across requests, more
6 specifically nonces. There are three different C{NonceStoreBase}
7 implementations for different needs. While the C{StatelessNonceStore} has
8 minimal requirements it only prevents replay attacks in a limited way. If the
9 WSGI server uses threading or a single process the C{MemoryNonceStore} can be
10 used. If that is not possible the nonces can be stored in a DBAPI2 compatible
11 database using C{DBAPI2NonceStore}.
12 """
13
14 __all__ = []
15
16 import base64
17 import hashlib
18 import time
19 import os
20 try:
21 from secrets import randbits, compare_digest
22 except ImportError:
23 import random
24 sysrand = random.SystemRandom()
25 randbits = sysrand.getrandbits
28
29 from wsgitools.internal import bytes2str, str2bytes, textopen
30 from wsgitools.authentication import AuthenticationRequired, \
31 ProtocolViolation, AuthenticationMiddleware
32
34 """
35 @type data: str
36 @rtype: str
37 """
38 return hashlib.md5(str2bytes(data)).hexdigest()
39
41 """
42 Generates a string of random base64 characters.
43 @param bytesentropy: is the number of random 8bit values to be used
44 @rtype: str
45
46 >>> gen_rand_str() != gen_rand_str()
47 True
48 """
49 randnum = randbits(bytesentropy*8)
50 randstr = ("%%0%dX" % (2*bytesentropy)) % randnum
51 randbytes = str2bytes(randstr)
52 randbytes = base64.b16decode(randbytes)
53 randbytes = base64.b64encode(randbytes)
54 randstr = bytes2str(randbytes)
55 return randstr
56
58 """internal
59 @raises ValueError:
60
61 >>> parse_digest_response('foo=bar')
62 {'foo': 'bar'}
63 >>> parse_digest_response('foo="bar"')
64 {'foo': 'bar'}
65 >>> sorted(parse_digest_response('foo="bar=qux",spam=egg').items())
66 [('foo', 'bar=qux'), ('spam', 'egg')]
67 >>> try:
68 ... parse_digest_response('spam')
69 ... except ValueError:
70 ... print("ValueError")
71 ValueError
72 >>> try:
73 ... parse_digest_response('spam="egg"error')
74 ... except ValueError:
75 ... print("ValueError")
76 ValueError
77 >>> # backslashes: doc string, eval => two backslashes
78 >>> parse_digest_response('backslash="\\\\\\\\"')
79 {'backslash': '\\\\'}
80 >>> parse_digest_response('foo="quo\\\\"te"')
81 {'foo': 'quo"te'}
82 """
83 assert isinstance(data, str)
84 result = dict()
85 while True:
86 data = data.strip()
87 key, data = data.split('=', 1)
88 if data.startswith('"'):
89 data = data[1:]
90 value = ""
91 while True:
92 part, data = data.split('"', 1)
93
94 escape = -1
95 for part in part.split('\\'):
96 escape += 1
97 if escape > 2:
98 value += "\\"
99 escape -= 2
100 if part:
101 escape = 0
102 value += part
103 if escape == 2:
104 value += "\\"
105 elif escape == 1:
106 value += '"'
107 continue
108 break
109 result[key] = value
110 if not data:
111 return result
112 if data[0] != ",":
113 raise ValueError("invalid digest response")
114 data = data[1:]
115 else:
116 if ',' not in data:
117 result[key] = data
118 return result
119 value, data = data.split(',', 1)
120 result[key] = value
121
144
147
148 __all__.append("AbstractTokenGenerator")
150 """Interface class for generating authentication tokens for
151 L{AuthDigestMiddleware}.
152
153 @ivar realm: is a string according to RFC2617.
154 @type realm: str
155 """
157 """
158 @type realm: str
159 """
160 assert isinstance(realm, str)
161 self.realm = realm
162
163 - def __call__(self, username, algo="md5"):
164 """Generates an authentication token from a username.
165 @type username: str
166 @type algo: str
167 @param algo: currently the only value supported by
168 L{AuthDigestMiddleware} is "md5"
169 @rtype: str or None
170 @returns: a valid token or None to signal that authentication should
171 fail
172 """
173 raise NotImplementedError
174
176 """
177 This function implements the interface for verifying passwords
178 used by L{BasicAuthMiddleware}. It works by computing a token
179 from the user and comparing it to the token returned by the
180 __call__ method.
181
182 @type username: str
183 @type password: str
184 @param environ: ignored
185 @rtype: bool
186 """
187 assert isinstance(username, str)
188 assert isinstance(password, str)
189 token = "%s:%s:%s" % (username, self.realm, password)
190 return compare_digest(md5hex(token), self(username))
191
192 __all__.append("AuthTokenGenerator")
194 """Generates authentication tokens for L{AuthDigestMiddleware}. The
195 interface consists of beeing callable with a username and having a
196 realm attribute being a string."""
198 """
199 @type realm: str
200 @param realm: is a string according to RFC2617.
201 @type getpass: str -> (str or None)
202 @param getpass: this function is called with a username and password is
203 expected as result. C{None} may be used as an invalid password.
204 An example for getpass would be C{{username: password}.get}.
205 """
206 AbstractTokenGenerator.__init__(self, realm)
207 self.getpass = getpass
208
209 - def __call__(self, username, algo="md5"):
210 assert isinstance(username, str)
211 assert algo.lower() in ["md5", "md5-sess"]
212 password = self.getpass(username)
213 if password is None:
214 return None
215 a1 = "%s:%s:%s" % (username, self.realm, password)
216 return md5hex(a1)
217
218 __all__.append("HtdigestTokenGenerator")
220 """Reads authentication tokens for L{AuthDigestMiddleware} from an
221 apache htdigest file.
222 """
223 - def __init__(self, realm, htdigestfile, ignoreparseerrors=False):
224 """
225 @type realm: str
226 @type htdigestfile: str
227 @param htdigestfile: path to the .htdigest file
228 @type ignoreparseerrors: bool
229 @param ignoreparseerrors: passed to readhtdigest
230 @raises IOError:
231 @raises ValueError:
232 """
233 AbstractTokenGenerator.__init__(self, realm)
234 self.users = {}
235 self.readhtdigest(htdigestfile, ignoreparseerrors)
236
237 - def readhtdigest(self, htdigestfile, ignoreparseerrors=False):
238 """
239 @type htdigestfile: str
240 @type ignoreparseerrors: bool
241 @param ignoreparseerrors: do not raise ValueErrors for bad files
242 @raises IOError:
243 @raises ValueError:
244 """
245 assert isinstance(htdigestfile, str)
246 self.users = {}
247 with textopen(htdigestfile, "r") as htdigest:
248 for line in htdigest:
249 parts = line.rstrip("\n").split(":")
250 if len(parts) != 3:
251 if ignoreparseerrors:
252 continue
253 raise ValueError("invalid number of colons in htdigest file")
254 user, realm, token = parts
255 if realm != self.realm:
256 continue
257 if user in self.users and not ignoreparseerrors:
258 raise ValueError("duplicate user in htdigest file")
259 self.users[user] = token
260
262 assert algo.lower() in ["md5", "md5-sess"]
263 return self.users.get(user)
264
265 __all__.append("UpdatingHtdigestTokenGenerator")
267 """Behaves like L{HtdigestTokenGenerator}, checks the htdigest file
268 for changes on each invocation.
269 """
270 - def __init__(self, realm, htdigestfile, ignoreparseerrors=False):
271 assert isinstance(htdigestfile, str)
272
273
274 try:
275 self.statcache = os.stat(htdigestfile)
276 except OSError as err:
277 raise IOError(str(err))
278 HtdigestTokenGenerator.__init__(self, realm, htdigestfile,
279 ignoreparseerrors)
280 self.htdigestfile = htdigestfile
281 self.ignoreparseerrors = ignoreparseerrors
282
284
285
286 try:
287 statcache = os.stat(self.htdigestfile)
288 except OSError:
289 return None
290 if self.statcache != statcache:
291 try:
292 self.readhtdigest(self.htdigestfile, self.ignoreparseerrors)
293 except IOError:
294 return None
295 except ValueError:
296 return None
297 return HtdigestTokenGenerator.__call__(self, user, algo)
298
299 __all__.append("NonceStoreBase")
301 """Nonce storage interface."""
305 """
306 This method is to be overriden and should return new nonces.
307 @type ident: str
308 @param ident: is an identifier to be associated with this nonce
309 @rtype: str
310 """
311 raise NotImplementedError
312 - def checknonce(self, nonce, count=1, ident=None):
313 """
314 This method is to be overridden and should do a check for whether the
315 given nonce is valid as being used count times.
316 @type nonce: str
317 @type count: int
318 @param count: indicates how often the nonce has been used (including
319 this check)
320 @type ident: str
321 @param ident: it is also checked that the nonce was associated to this
322 identifier when given
323 @rtype: bool
324 """
325 raise NotImplementedError
326
335
336 __all__.append("StatelessNonceStore")
338 """
339 This is a stateless nonce storage that cannot check the usage count for
340 a nonce and thus cannot protect against replay attacks. It however can make
341 it difficult by posing a timeout on nonces and making it difficult to forge
342 nonces.
343
344 This nonce store is usable with L{scgi.forkpool}.
345
346 >>> s = StatelessNonceStore()
347 >>> n = s.newnonce()
348 >>> s.checknonce("spam")
349 False
350 >>> s.checknonce(n)
351 True
352 >>> s.checknonce(n)
353 True
354 >>> s.checknonce(n.rsplit(':', 1)[0] + "bad hash")
355 False
356 """
357 - def __init__(self, maxage=300, secret=None):
358 """
359 @type maxage: int
360 @param maxage: is the number of seconds a nonce may be valid. Choosing a
361 large value may result in more memory usage whereas a smaller
362 value results in more requests. Defaults to 5 minutes.
363 @type secret: str
364 @param secret: if not given, a secret is generated and is therefore
365 shared after forks. Knowing this secret permits creating nonces.
366 """
367 NonceStoreBase.__init__(self)
368 self.maxage = maxage
369 if secret:
370 self.server_secret = secret
371 else:
372 self.server_secret = gen_rand_str()
373
375 """
376 Generates a new nonce string.
377 @type ident: None or str
378 @rtype: str
379 """
380 nonce_time = format_time(time.time())
381 nonce_value = gen_rand_str()
382 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
383 if ident is not None:
384 token = "%s:%s" % (token, ident)
385 token = md5hex(token)
386 return "%s:%s:%s" % (nonce_time, nonce_value, token)
387
388 - def checknonce(self, nonce, count=1, ident=None):
389 """
390 Check whether the provided string is a nonce.
391 @type nonce: str
392 @type count: int
393 @type ident: None or str
394 @rtype: bool
395 """
396 if count != 1:
397 return False
398 try:
399 nonce_time, nonce_value, nonce_hash = nonce.split(':')
400 except ValueError:
401 return False
402 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
403 if ident is not None:
404 token = "%s:%s" % (token, ident)
405 token = md5hex(token)
406 if token != nonce_hash:
407 return False
408
409 if nonce_time < format_time(time.time() - self.maxage):
410 return False
411 return True
412
413 __all__.append("MemoryNonceStore")
415 """
416 Simple in-memory mechanism to store nonces.
417
418 >>> s = MemoryNonceStore(maxuses=1)
419 >>> n = s.newnonce()
420 >>> s.checknonce("spam")
421 False
422 >>> s.checknonce(n)
423 True
424 >>> s.checknonce(n)
425 False
426 >>> n = s.newnonce()
427 >>> s.checknonce(n.rsplit(':', 1)[0] + "bad hash")
428 False
429 """
430 - def __init__(self, maxage=300, maxuses=5):
431 """
432 @type maxage: int
433 @param maxage: is the number of seconds a nonce may be valid. Choosing a
434 large value may result in more memory usage whereas a smaller
435 value results in more requests. Defaults to 5 minutes.
436 @type maxuses: int
437 @param maxuses: is the number of times a nonce may be used (with
438 different nc values). A value of 1 makes nonces usable exactly
439 once resulting in more requests. Defaults to 5.
440 """
441 NonceStoreBase.__init__(self)
442 self.maxage = maxage
443 self.maxuses = maxuses
444 self.nonces = []
445
446 self.server_secret = gen_rand_str()
447
449 """internal methods cleaning list of valid nonces"""
450 old = format_time(time.time() - self.maxage)
451 while self.nonces and self.nonces[0][0] < old:
452 self.nonces.pop(0)
453
455 """
456 Generates a new nonce string.
457 @type ident: None or str
458 @rtype: str
459 """
460 self._cleanup()
461 nonce_time = format_time(time.time())
462 nonce_value = gen_rand_str()
463 self.nonces.append((nonce_time, nonce_value, 1))
464 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
465 if ident is not None:
466 token = "%s:%s" % (token, ident)
467 token = md5hex(token)
468 return "%s:%s:%s" % (nonce_time, nonce_value, token)
469
470 - def checknonce(self, nonce, count=1, ident=None):
471 """
472 Do a check for whether the provided string is a nonce and increase usage
473 count on returning True.
474 @type nonce: str
475 @type count: int
476 @type ident: None or str
477 @rtype: bool
478 """
479 try:
480 nonce_time, nonce_value, nonce_hash = nonce.split(':')
481 except ValueError:
482 return False
483 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
484 if ident is not None:
485 token = "%s:%s" % (token, ident)
486 token = md5hex(token)
487 if token != nonce_hash:
488 return False
489
490 self._cleanup()
491
492
493 lower, upper = 0, len(self.nonces) - 1
494 while lower < upper:
495 mid = (lower + upper) // 2
496 if nonce_time <= self.nonces[mid][0]:
497 upper = mid
498 else:
499 lower = mid + 1
500
501 if len(self.nonces) <= lower:
502 return False
503 (nt, nv, uses) = self.nonces[lower]
504 if nt != nonce_time or nv != nonce_value:
505 return False
506 if count != uses:
507 del self.nonces[lower]
508 return False
509 if uses >= self.maxuses:
510 del self.nonces[lower]
511 else:
512 self.nonces[lower] = (nt, nv, uses+1)
513 return True
514
515 __all__.append("LazyDBAPI2Opener")
517 """
518 Connects to database on first request. Otherwise it behaves like a dbapi2
519 connection. This may be usefull in combination with L{scgi.forkpool},
520 because this way each worker child opens a new database connection when
521 the first request is to be answered.
522 """
523 - def __init__(self, function, *args, **kwargs):
524 """
525 The database will be connected on the first method call. This is done
526 by calling the given function with the remaining parameters.
527 @param function: is the function that connects to the database
528 """
529 self._function = function
530 self._args = args
531 self._kwargs = kwargs
532 self._dbhandle = None
534 """Returns an open database connection. Open if necessary."""
535 if self._dbhandle is None:
536 self._dbhandle = self._function(*self._args, **self._kwargs)
537 self._function = self._args = self._kwargs = None
538 return self._dbhandle
551
552 __all__.append("DBAPI2NonceStore")
554 """
555 A dbapi2-backed nonce store implementation suitable for usage with forking
556 wsgi servers such as L{scgi.forkpool}.
557
558 >>> import sqlite3
559 >>> db = sqlite3.connect(":memory:")
560 >>> db.cursor().execute("CREATE TABLE nonces (key, value);") and None
561 >>> db.commit() and None
562 >>> s = DBAPI2NonceStore(db, maxuses=1)
563 >>> n = s.newnonce()
564 >>> s.checknonce("spam")
565 False
566 >>> s.checknonce(n)
567 True
568 >>> s.checknonce(n)
569 False
570 >>> n = s.newnonce()
571 >>> s.checknonce(n.rsplit(':', 1)[0] + "bad hash")
572 False
573 """
574 - def __init__(self, dbhandle, maxage=300, maxuses=5, table="nonces"):
575 """
576 @param dbhandle: is a dbapi2 connection
577 @type maxage: int
578 @param maxage: is the number of seconds a nonce may be valid. Choosing a
579 large value may result in more memory usage whereas a smaller
580 value results in more requests. Defaults to 5 minutes.
581 @type maxuses: int
582 @param maxuses: is the number of times a nonce may be used (with
583 different nc values). A value of 1 makes nonces usable exactly
584 once resulting in more requests. Defaults to 5.
585 """
586 NonceStoreBase.__init__(self)
587 self.dbhandle = dbhandle
588 self.maxage = maxage
589 self.maxuses = maxuses
590 self.table = table
591 self.server_secret = gen_rand_str()
592
594 """internal methods cleaning list of valid nonces"""
595 old = format_time(time.time() - self.maxage)
596 cur.execute("DELETE FROM %s WHERE key < '%s:';" % (self.table, old))
597
599 """
600 Generates a new nonce string.
601 @rtype: str
602 """
603 nonce_time = format_time(time.time())
604 nonce_value = gen_rand_str()
605 dbkey = "%s:%s" % (nonce_time, nonce_value)
606 cur = self.dbhandle.cursor()
607 self._cleanup(cur)
608 cur.execute("INSERT INTO %s VALUES ('%s', '1');" % (self.table, dbkey))
609 self.dbhandle.commit()
610 token = "%s:%s" % (dbkey, self.server_secret)
611 if ident is not None:
612 token = "%s:%s" % (token, ident)
613 token = md5hex(token)
614 return "%s:%s:%s" % (nonce_time, nonce_value, token)
615
616 - def checknonce(self, nonce, count=1, ident=None):
617 """
618 Do a check for whether the provided string is a nonce and increase usage
619 count on returning True.
620 @type nonce: str
621 @type count: int
622 @type ident: str or None
623 @rtype: bool
624 """
625 try:
626 nonce_time, nonce_value, nonce_hash = nonce.split(':')
627 except ValueError:
628 return False
629
630 if not str2bytes(nonce_time).isalnum() or \
631 not str2bytes(nonce_value.replace("+", "").replace("/", "") \
632 .replace("=", "")).isalnum():
633 return False
634 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
635 if ident is not None:
636 token = "%s:%s" % (token, ident)
637 token = md5hex(token)
638 if token != nonce_hash:
639 return False
640
641 if nonce_time < format_time(time.time() - self.maxage):
642 return False
643
644 cur = self.dbhandle.cursor()
645
646
647 dbkey = "%s:%s" % (nonce_time, nonce_value)
648 cur.execute("SELECT value FROM %s WHERE key = '%s';" %
649 (self.table, dbkey))
650 uses = cur.fetchone()
651 if uses is None:
652 self.dbhandle.commit()
653 return False
654 uses = int(uses[0])
655 if count != uses:
656 cur.execute("DELETE FROM %s WHERE key = '%s';" %
657 (self.table, dbkey))
658 self.dbhandle.commit()
659 return False
660 if uses >= self.maxuses:
661 cur.execute("DELETE FROM %s WHERE key = '%s';" %
662 (self.table, dbkey))
663 else:
664 cur.execute("UPDATE %s SET value = '%d' WHERE key = '%s';" %
665 (self.table, uses + 1, dbkey))
666 self.dbhandle.commit()
667 return True
668
670 """internal method for verifying the uri credential
671 @raises AuthenticationRequired:
672 """
673
674
675
676 try:
677 uri = credentials["uri"]
678 except KeyError:
679 raise ProtocolViolation("uri missing in client credentials")
680 if environ.get("QUERY_STRING"):
681 if not uri.endswith(environ["QUERY_STRING"]):
682 raise AuthenticationRequired("url mismatch")
683 uri = uri[:-len(environ["QUERY_STRING"])]
684 if environ.get("SCRIPT_NAME"):
685 if not uri.startswith(environ["SCRIPT_NAME"]):
686 raise AuthenticationRequired("url mismatch")
687 uri = uri[len(environ["SCRIPT_NAME"]):]
688 if environ.get("PATH_INFO"):
689 if not uri.startswith(environ["PATH_INFO"]):
690 raise AuthenticationRequired("url mismatch")
691 uri = uri[len(environ["PATH_INFO"]):]
692 if uri not in ('', '?'):
693 raise AuthenticationRequired("url mismatch")
694
695 __all__.append("AuthDigestMiddleware")
697 """Middleware partly implementing RFC2617. (md5-sess was omited)
698 Upon successful authentication the environ dict will be extended
699 by a REMOTE_USER key before being passed to the wrapped
700 application."""
701 authorization_method = "digest"
702 algorithms = {"md5": md5hex}
703 - def __init__(self, app, gentoken, maxage=300, maxuses=5, store=None):
704 """
705 @param app: is the wsgi application to be served with authentication.
706 @type gentoken: str -> (str or None)
707 @param gentoken: has to have the same functionality and interface as the
708 L{AuthTokenGenerator} class.
709 @type maxage: int
710 @param maxage: deprecated, see L{MemoryNonceStore} or
711 L{StatelessNonceStore} and pass an instance to store
712 @type maxuses: int
713 @param maxuses: deprecated, see L{MemoryNonceStore} and pass an
714 instance to store
715 @type store: L{NonceStoreBase}
716 @param store: a nonce storage implementation object. Usage of this
717 parameter will override maxage and maxuses.
718 """
719 AuthenticationMiddleware.__init__(self, app)
720 self.gentoken = gentoken
721 if store is None:
722 self.noncestore = MemoryNonceStore(maxage, maxuses)
723 else:
724 assert hasattr(store, "newnonce")
725 assert hasattr(store, "checknonce")
726 self.noncestore = store
727
729 assert isinstance(auth, str)
730 try:
731 credentials = parse_digest_response(auth)
732 except ValueError:
733 raise ProtocolViolation("failed to parse digest response")
734
735
736 credentials["algorithm"] = credentials.get("algorithm",
737 "md5").lower()
738 if not credentials["algorithm"] in self.algorithms:
739 raise ProtocolViolation("algorithm not implemented: %r" %
740 credentials["algorithm"])
741
742 check_uri(credentials, environ)
743
744 try:
745 nonce = credentials["nonce"]
746 credresponse = credentials["response"]
747 except KeyError as err:
748 raise ProtocolViolation("%s missing in credentials" %
749 err.args[0])
750 noncecount = 1
751 if "qop" in credentials:
752 if credentials["qop"] != "auth":
753 raise ProtocolViolation("unimplemented qop: %r" %
754 credentials["qop"])
755 try:
756 noncecount = int(credentials["nc"], 16)
757 except KeyError:
758 raise ProtocolViolation("nc missing in qop=auth")
759 except ValueError:
760 raise ProtocolViolation("non hexdigit found in nonce count")
761
762
763 response = self.auth_response(credentials,
764 environ["REQUEST_METHOD"])
765
766 if not self.noncestore.checknonce(nonce, noncecount):
767 raise StaleNonce()
768
769 if response is None or response != credresponse:
770 raise AuthenticationRequired("wrong response")
771
772 digest = dict(nextnonce=(self.noncestore.newnonce(), True))
773 if "qop" in credentials:
774 digest["qop"] = ("auth", False)
775 digest["cnonce"] = (credentials["cnonce"], True)
776 digest["rspauth"] = (self.auth_response(credentials, ""), True)
777 return dict(user=credentials["username"],
778 outheaders=[("Authentication-Info", format_digest(digest))])
779
781 """internal method generating authentication tokens
782 @raises AuthenticationRequired:
783 """
784 try:
785 username = credentials["username"]
786 algo = credentials["algorithm"]
787 uri = credentials["uri"]
788 except KeyError as err:
789 raise ProtocolViolation("%s missing in credentials" % err.args[0])
790 try:
791 dig = [credentials["nonce"]]
792 except KeyError:
793 raise ProtocolViolation("missing nonce in credentials")
794 qop = credentials.get("qop")
795 if qop is not None:
796 if qop != "auth":
797 raise AuthenticationRequired("unimplemented qop: %r" % qop)
798 try:
799 dig.append(credentials["nc"])
800 dig.append(credentials["cnonce"])
801 except KeyError as err:
802 raise ProtocolViolation(
803 "missing %s in credentials with qop=auth" % err.args[0])
804 dig.append(qop)
805 dig.append(self.algorithms[algo]("%s:%s" % (reqmethod, uri)))
806 try:
807 a1h = self.gentoken(username, algo)
808 except TypeError:
809 a1h = self.gentoken(username)
810 if a1h is None:
811 return None
812 dig.insert(0, a1h)
813 return self.algorithms[algo](":".join(dig))
814
816 digest = dict(nonce=(self.noncestore.newnonce(), True),
817 realm=(self.gentoken.realm, True),
818 algorithm=("MD5", False),
819 qop=("auth", False))
820 if isinstance(exception, StaleNonce):
821 digest["stale"] = ("TRUE", False)
822 challenge = format_digest(digest)
823 return ("WWW-Authenticate", "Digest %s" % challenge)
824