1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 """DIGEST-MD5 authentication mechanism for PyXMPP SASL implementation.
18
19 Normative reference:
20 - `RFC 2831 <http://www.ietf.org/rfc/rfc2831.txt>`__
21 """
22
23 __docformat__="restructuredtext en"
24
25 from binascii import b2a_hex
26 import re
27 import logging
28
29 import hashlib
30
31 from pyxmpp.sasl.core import ClientAuthenticator,ServerAuthenticator
32 from pyxmpp.sasl.core import Failure,Response,Challenge,Success,Failure
33
34 from pyxmpp.utils import to_utf8,from_utf8
35
36 quote_re=re.compile(r"(?<!\\)\\(.)")
37
39 """Unquote quoted value from DIGEST-MD5 challenge or response.
40
41 If `s` doesn't start or doesn't end with '"' then return it unchanged,
42 remove the quotes and escape backslashes otherwise.
43
44 :Parameters:
45 - `s`: a quoted string.
46 :Types:
47 - `s`: `str`
48
49 :return: the unquoted string.
50 :returntype: `str`"""
51 if not s.startswith('"') or not s.endswith('"'):
52 return s
53 return quote_re.sub(r"\1",s[1:-1])
54
56 """Prepare a string for quoting for DIGEST-MD5 challenge or response.
57
58 Don't add the quotes, only escape '"' and "\\" with backslashes.
59
60 :Parameters:
61 - `s`: a raw string.
62 :Types:
63 - `s`: `str`
64
65 :return: `s` with '"' and "\\" escaped using "\\".
66 :returntype: `str`"""
67 s=s.replace('\\','\\\\')
68 s=s.replace('"','\\"')
69 return '%s' % (s,)
70
72 """H function of the DIGEST-MD5 algorithm (MD5 sum).
73
74 :Parameters:
75 - `s`: a string.
76 :Types:
77 - `s`: `str`
78
79 :return: MD5 sum of the string.
80 :returntype: `str`"""
81 return hashlib.md5(s).digest()
82
84 """KD function of the DIGEST-MD5 algorithm.
85
86 :Parameters:
87 - `k`: a string.
88 - `s`: a string.
89 :Types:
90 - `k`: `str`
91 - `s`: `str`
92
93 :return: MD5 sum of the strings joined with ':'.
94 :returntype: `str`"""
95 return _h_value("%s:%s" % (k,s))
96
98 """Compute MD5 sum of username:realm:password.
99
100 :Parameters:
101 - `username`: a username.
102 - `realm`: a realm.
103 - `passwd`: a password.
104 :Types:
105 - `username`: `str`
106 - `realm`: `str`
107 - `passwd`: `str`
108
109 :return: the MD5 sum of the parameters joined with ':'.
110 :returntype: `str`"""
111 if realm is None:
112 realm=""
113 if type(passwd) is unicode:
114 passwd=passwd.encode("utf-8")
115 return _h_value("%s:%s:%s" % (username,realm,passwd))
116
118 """Compute DIGEST-MD5 response value.
119
120 :Parameters:
121 - `urp_hash`: MD5 sum of username:realm:password.
122 - `nonce`: nonce value from a server challenge.
123 - `cnonce`: cnonce value from the client response.
124 - `nonce_count`: nonce count value.
125 - `authzid`: authorization id.
126 - `digest_uri`: digest-uri value.
127 :Types:
128 - `urp_hash`: `str`
129 - `nonce`: `str`
130 - `nonce_count`: `int`
131 - `authzid`: `str`
132 - `digest_uri`: `str`
133
134 :return: the computed response value.
135 :returntype: `str`"""
136 if authzid:
137 a1="%s:%s:%s:%s" % (urp_hash,nonce,cnonce,authzid)
138 else:
139 a1="%s:%s:%s" % (urp_hash,nonce,cnonce)
140 a2="AUTHENTICATE:"+digest_uri
141 return b2a_hex(_kd_value( b2a_hex(_h_value(a1)),"%s:%s:%s:%s:%s" % (
142 nonce,nonce_count,
143 cnonce,"auth",b2a_hex(_h_value(a2)) ) ))
144
146 """Compute DIGEST-MD5 rspauth value.
147
148 :Parameters:
149 - `urp_hash`: MD5 sum of username:realm:password.
150 - `nonce`: nonce value from a server challenge.
151 - `cnonce`: cnonce value from the client response.
152 - `nonce_count`: nonce count value.
153 - `authzid`: authorization id.
154 - `digest_uri`: digest-uri value.
155 :Types:
156 - `urp_hash`: `str`
157 - `nonce`: `str`
158 - `nonce_count`: `int`
159 - `authzid`: `str`
160 - `digest_uri`: `str`
161
162 :return: the computed rspauth value.
163 :returntype: `str`"""
164 if authzid:
165 a1="%s:%s:%s:%s" % (urp_hash,nonce,cnonce,authzid)
166 else:
167 a1="%s:%s:%s" % (urp_hash,nonce,cnonce)
168 a2=":"+digest_uri
169 return b2a_hex(_kd_value( b2a_hex(_h_value(a1)),"%s:%s:%s:%s:%s" % (
170 nonce,nonce_count,
171 cnonce,"auth",b2a_hex(_h_value(a2)) ) ))
172
173 _param_re=re.compile(r'^(?P<var>[^=]+)\=(?P<val>(\"(([^"\\]+)|(\\\")'
174 r'|(\\\\))+\")|([^",]+))(\s*\,\s*(?P<rest>.*))?$')
175
177 """Provides PLAIN SASL authentication for a client.
178
179 :Ivariables:
180 - `password`: current authentication password
181 - `pformat`: current authentication password format
182 - `realm`: current authentication realm
183 """
184
186 """Initialize a `DigestMD5ClientAuthenticator` object.
187
188 :Parameters:
189 - `password_manager`: name of the password manager object providing
190 authentication credentials.
191 :Types:
192 - `password_manager`: `PasswordManager`"""
193 ClientAuthenticator.__init__(self,password_manager)
194 self.username=None
195 self.rspauth_checked=None
196 self.response_auth=None
197 self.authzid=None
198 self.pformat=None
199 self.realm=None
200 self.password=None
201 self.nonce_count=None
202 self.__logger=logging.getLogger("pyxmpp.sasl.DigestMD5ClientAuthenticator")
203
204 - def start(self,username,authzid):
205 """Start the authentication process initializing client state.
206
207 :Parameters:
208 - `username`: username (authentication id).
209 - `authzid`: authorization id.
210 :Types:
211 - `username`: `unicode`
212 - `authzid`: `unicode`
213
214 :return: the (empty) initial response
215 :returntype: `sasl.Response` or `sasl.Failure`"""
216 self.username=from_utf8(username)
217 if authzid:
218 self.authzid=from_utf8(authzid)
219 else:
220 self.authzid=""
221 self.password=None
222 self.pformat=None
223 self.nonce_count=0
224 self.response_auth=None
225 self.rspauth_checked=0
226 self.realm=None
227 return Response()
228
230 """Process a challenge and return the response.
231
232 :Parameters:
233 - `challenge`: the challenge from server.
234 :Types:
235 - `challenge`: `str`
236
237 :return: the response or a failure indicator.
238 :returntype: `sasl.Response` or `sasl.Failure`"""
239 if not challenge:
240 self.__logger.debug("Empty challenge")
241 return Failure("bad-challenge")
242 challenge=challenge.split('\x00')[0]
243 if self.response_auth:
244 return self._final_challenge(challenge)
245 realms=[]
246 nonce=None
247 charset="iso-8859-1"
248 while challenge:
249 m=_param_re.match(challenge)
250 if not m:
251 self.__logger.debug("Challenge syntax error: %r" % (challenge,))
252 return Failure("bad-challenge")
253 challenge=m.group("rest")
254 var=m.group("var")
255 val=m.group("val")
256 self.__logger.debug("%r: %r" % (var,val))
257 if var=="realm":
258 realms.append(_unquote(val))
259 elif var=="nonce":
260 if nonce:
261 self.__logger.debug("Duplicate nonce")
262 return Failure("bad-challenge")
263 nonce=_unquote(val)
264 elif var=="qop":
265 qopl=_unquote(val).split(",")
266 if "auth" not in qopl:
267 self.__logger.debug("auth not supported")
268 return Failure("not-implemented")
269 elif var=="charset":
270 if val!="utf-8":
271 self.__logger.debug("charset given and not utf-8")
272 return Failure("bad-challenge")
273 charset="utf-8"
274 elif var=="algorithm":
275 if val!="md5-sess":
276 self.__logger.debug("algorithm given and not md5-sess")
277 return Failure("bad-challenge")
278 if not nonce:
279 self.__logger.debug("nonce not given")
280 return Failure("bad-challenge")
281 self._get_password()
282 return self._make_response(charset,realms,nonce)
283
285 """Retrieve user's password from the password manager.
286
287 Set `self.password` to the password and `self.pformat`
288 to its format name ('plain' or 'md5:user:realm:pass')."""
289 if self.password is None:
290 self.password,self.pformat=self.password_manager.get_password(
291 self.username,["plain","md5:user:realm:pass"])
292 if not self.password or self.pformat not in ("plain","md5:user:realm:pass"):
293 self.__logger.debug("Couldn't get plain password. Password: %r Format: %r"
294 % (self.password,self.pformat))
295 return Failure("password-unavailable")
296
298 """Make a response for the first challenge from the server.
299
300 :Parameters:
301 - `charset`: charset name from the challenge.
302 - `realms`: realms list from the challenge.
303 - `nonce`: nonce value from the challenge.
304 :Types:
305 - `charset`: `str`
306 - `realms`: `str`
307 - `nonce`: `str`
308
309 :return: the response or a failure indicator.
310 :returntype: `sasl.Response` or `sasl.Failure`"""
311 params=[]
312 realm=self._get_realm(realms,charset)
313 if isinstance(realm,Failure):
314 return realm
315 elif realm:
316 realm=_quote(realm)
317 params.append('realm="%s"' % (realm,))
318
319 try:
320 username=self.username.encode(charset)
321 except UnicodeError:
322 self.__logger.debug("Couldn't encode username to %r" % (charset,))
323 return Failure("incompatible-charset")
324
325 username=_quote(username)
326 params.append('username="%s"' % (username,))
327
328 cnonce=self.password_manager.generate_nonce()
329 cnonce=_quote(cnonce)
330 params.append('cnonce="%s"' % (cnonce,))
331
332 params.append('nonce="%s"' % (_quote(nonce),))
333
334 self.nonce_count+=1
335 nonce_count="%08x" % (self.nonce_count,)
336 params.append('nc=%s' % (nonce_count,))
337
338 params.append('qop=auth')
339
340 serv_type=self.password_manager.get_serv_type().encode("us-ascii")
341 host=self.password_manager.get_serv_host().encode("idna")
342 serv_name=self.password_manager.get_serv_name().encode("utf-8")
343
344 if serv_name and serv_name != host:
345 digest_uri="%s/%s/%s" % (serv_type,host,serv_name)
346 else:
347 digest_uri="%s/%s" % (serv_type,host)
348
349 digest_uri=_quote(digest_uri)
350 params.append('digest-uri="%s"' % (digest_uri,))
351
352 if self.authzid:
353 try:
354 authzid=self.authzid.encode(charset)
355 except UnicodeError:
356 self.__logger.debug("Couldn't encode authzid to %r" % (charset,))
357 return Failure("incompatible-charset")
358 authzid=_quote(authzid)
359 else:
360 authzid=""
361
362 if self.pformat=="md5:user:realm:pass":
363 urp_hash=self.password
364 else:
365 urp_hash=_make_urp_hash(username,realm,self.password)
366
367 response=_compute_response(urp_hash,nonce,cnonce,nonce_count,
368 authzid,digest_uri)
369 self.response_auth=_compute_response_auth(urp_hash,nonce,cnonce,
370 nonce_count,authzid,digest_uri)
371 params.append('response=%s' % (response,))
372 if authzid:
373 params.append('authzid="%s"' % (authzid,))
374 return Response(",".join(params))
375
377 """Choose a realm from the list specified by the server.
378
379 :Parameters:
380 - `realms`: the realm list.
381 - `charset`: encoding of realms on the list.
382 :Types:
383 - `realms`: `list` of `str`
384 - `charset`: `str`
385
386 :return: the realm chosen or a failure indicator.
387 :returntype: `str` or `Failure`"""
388 if realms:
389 realms=[unicode(r,charset) for r in realms]
390 realm=self.password_manager.choose_realm(realms)
391 else:
392 realm=self.password_manager.choose_realm([])
393 if realm:
394 if type(realm) is unicode:
395 try:
396 realm=realm.encode(charset)
397 except UnicodeError:
398 self.__logger.debug("Couldn't encode realm to %r" % (charset,))
399 return Failure("incompatible-charset")
400 elif charset!="utf-8":
401 try:
402 realm=unicode(realm,"utf-8").encode(charset)
403 except UnicodeError:
404 self.__logger.debug("Couldn't encode realm from utf-8 to %r"
405 % (charset,))
406 return Failure("incompatible-charset")
407 self.realm=realm
408 return realm
409
411 """Process the second challenge from the server and return the response.
412
413 :Parameters:
414 - `challenge`: the challenge from server.
415 :Types:
416 - `challenge`: `str`
417
418 :return: the response or a failure indicator.
419 :returntype: `sasl.Response` or `sasl.Failure`"""
420 if self.rspauth_checked:
421 return Failure("extra-challenge")
422 challenge=challenge.split('\x00')[0]
423 rspauth=None
424 while challenge:
425 m=_param_re.match(challenge)
426 if not m:
427 self.__logger.debug("Challenge syntax error: %r" % (challenge,))
428 return Failure("bad-challenge")
429 challenge=m.group("rest")
430 var=m.group("var")
431 val=m.group("val")
432 self.__logger.debug("%r: %r" % (var,val))
433 if var=="rspauth":
434 rspauth=val
435 if not rspauth:
436 self.__logger.debug("Final challenge without rspauth")
437 return Failure("bad-success")
438 if rspauth==self.response_auth:
439 self.rspauth_checked=1
440 return Response("")
441 else:
442 self.__logger.debug("Wrong rspauth value - peer is cheating?")
443 self.__logger.debug("my rspauth: %r" % (self.response_auth,))
444 return Failure("bad-success")
445
447 """Process success indicator from the server.
448
449 Process any addiitional data passed with the success.
450 Fail if the server was not authenticated.
451
452 :Parameters:
453 - `data`: an optional additional data with success.
454 :Types:
455 - `data`: `str`
456
457 :return: success or failure indicator.
458 :returntype: `sasl.Success` or `sasl.Failure`"""
459 if not self.response_auth:
460 self.__logger.debug("Got success too early")
461 return Failure("bad-success")
462 if self.rspauth_checked:
463 return Success(self.username,self.realm,self.authzid)
464 else:
465 r = self._final_challenge(data)
466 if isinstance(r, Failure):
467 return r
468 if self.rspauth_checked:
469 return Success(self.username,self.realm,self.authzid)
470 else:
471 self.__logger.debug("Something went wrong when processing additional data with success?")
472 return Failure("bad-success")
473
475 """Provides DIGEST-MD5 SASL authentication for a server."""
476
478 """Initialize a `DigestMD5ServerAuthenticator` object.
479
480 :Parameters:
481 - `password_manager`: name of the password manager object providing
482 authentication credential verification.
483 :Types:
484 - `password_manager`: `PasswordManager`"""
485 ServerAuthenticator.__init__(self,password_manager)
486 self.nonce=None
487 self.username=None
488 self.realm=None
489 self.authzid=None
490 self.done=None
491 self.last_nonce_count=None
492 self.__logger=logging.getLogger("pyxmpp.sasl.DigestMD5ServerAuthenticator")
493
494 - def start(self,response):
495 """Start the authentication process.
496
497 :Parameters:
498 - `response`: the initial response from the client (empty for
499 DIGEST-MD5).
500 :Types:
501 - `response`: `str`
502
503 :return: a challenge, a success indicator or a failure indicator.
504 :returntype: `sasl.Challenge`, `sasl.Success` or `sasl.Failure`"""
505 _unused = response
506 self.last_nonce_count=0
507 params=[]
508 realms=self.password_manager.get_realms()
509 if realms:
510 self.realm=_quote(realms[0])
511 for r in realms:
512 r=_quote(r)
513 params.append('realm="%s"' % (r,))
514 else:
515 self.realm=None
516 nonce=_quote(self.password_manager.generate_nonce())
517 self.nonce=nonce
518 params.append('nonce="%s"' % (nonce,))
519 params.append('qop="auth"')
520 params.append('charset=utf-8')
521 params.append('algorithm=md5-sess')
522 self.authzid=None
523 self.done=0
524 return Challenge(",".join(params))
525
527 """Process a client reponse.
528
529 :Parameters:
530 - `response`: the response from the client.
531 :Types:
532 - `response`: `str`
533
534 :return: a challenge, a success indicator or a failure indicator.
535 :returntype: `sasl.Challenge`, `sasl.Success` or `sasl.Failure`"""
536 if self.done:
537 return Success(self.username,self.realm,self.authzid)
538 if not response:
539 return Failure("not-authorized")
540 return self._parse_response(response)
541
543 """Parse a client reponse and pass to further processing.
544
545 :Parameters:
546 - `response`: the response from the client.
547 :Types:
548 - `response`: `str`
549
550 :return: a challenge, a success indicator or a failure indicator.
551 :returntype: `sasl.Challenge`, `sasl.Success` or `sasl.Failure`"""
552 response=response.split('\x00')[0]
553 if self.realm:
554 realm=to_utf8(self.realm)
555 realm=_quote(realm)
556 else:
557 realm=None
558 username=None
559 cnonce=None
560 digest_uri=None
561 response_val=None
562 authzid=None
563 nonce_count=None
564 while response:
565 m=_param_re.match(response)
566 if not m:
567 self.__logger.debug("Response syntax error: %r" % (response,))
568 return Failure("not-authorized")
569 response=m.group("rest")
570 var=m.group("var")
571 val=m.group("val")
572 self.__logger.debug("%r: %r" % (var,val))
573 if var=="realm":
574 realm=val[1:-1]
575 elif var=="cnonce":
576 if cnonce:
577 self.__logger.debug("Duplicate cnonce")
578 return Failure("not-authorized")
579 cnonce=val[1:-1]
580 elif var=="qop":
581 if val!='auth':
582 self.__logger.debug("qop other then 'auth'")
583 return Failure("not-authorized")
584 elif var=="digest-uri":
585 digest_uri=val[1:-1]
586 elif var=="authzid":
587 authzid=val[1:-1]
588 elif var=="username":
589 username=val[1:-1]
590 elif var=="response":
591 response_val=val
592 elif var=="nc":
593 nonce_count=val
594 self.last_nonce_count+=1
595 if int(nonce_count)!=self.last_nonce_count:
596 self.__logger.debug("bad nonce: %r != %r"
597 % (nonce_count,self.last_nonce_count))
598 return Failure("not-authorized")
599 return self._check_params(username,realm,cnonce,digest_uri,
600 response_val,authzid,nonce_count)
601
602 - def _check_params(self,username,realm,cnonce,digest_uri,
603 response_val,authzid,nonce_count):
604 """Check parameters of a client reponse and pass them to further
605 processing.
606
607 :Parameters:
608 - `username`: user name.
609 - `realm`: realm.
610 - `cnonce`: cnonce value.
611 - `digest_uri`: digest-uri value.
612 - `response_val`: response value computed by the client.
613 - `authzid`: authorization id.
614 - `nonce_count`: nonce count value.
615 :Types:
616 - `username`: `str`
617 - `realm`: `str`
618 - `cnonce`: `str`
619 - `digest_uri`: `str`
620 - `response_val`: `str`
621 - `authzid`: `str`
622 - `nonce_count`: `int`
623
624 :return: a challenge, a success indicator or a failure indicator.
625 :returntype: `sasl.Challenge`, `sasl.Success` or `sasl.Failure`"""
626 if not cnonce:
627 self.__logger.debug("Required 'cnonce' parameter not given")
628 return Failure("not-authorized")
629 if not response_val:
630 self.__logger.debug("Required 'response' parameter not given")
631 return Failure("not-authorized")
632 if not username:
633 self.__logger.debug("Required 'username' parameter not given")
634 return Failure("not-authorized")
635 if not digest_uri:
636 self.__logger.debug("Required 'digest_uri' parameter not given")
637 return Failure("not-authorized")
638 if not nonce_count:
639 self.__logger.debug("Required 'nc' parameter not given")
640 return Failure("not-authorized")
641 return self._make_final_challenge(username,realm,cnonce,digest_uri,
642 response_val,authzid,nonce_count)
643
646 """Send the second challenge in reply to the client response.
647
648 :Parameters:
649 - `username`: user name.
650 - `realm`: realm.
651 - `cnonce`: cnonce value.
652 - `digest_uri`: digest-uri value.
653 - `response_val`: response value computed by the client.
654 - `authzid`: authorization id.
655 - `nonce_count`: nonce count value.
656 :Types:
657 - `username`: `str`
658 - `realm`: `str`
659 - `cnonce`: `str`
660 - `digest_uri`: `str`
661 - `response_val`: `str`
662 - `authzid`: `str`
663 - `nonce_count`: `int`
664
665 :return: a challenge, a success indicator or a failure indicator.
666 :returntype: `sasl.Challenge`, `sasl.Success` or `sasl.Failure`"""
667 username_uq=from_utf8(username.replace('\\',''))
668 if authzid:
669 authzid_uq=from_utf8(authzid.replace('\\',''))
670 else:
671 authzid_uq=None
672 if realm:
673 realm_uq=from_utf8(realm.replace('\\',''))
674 else:
675 realm_uq=None
676 digest_uri_uq=digest_uri.replace('\\','')
677 self.username=username_uq
678 self.realm=realm_uq
679 password,pformat=self.password_manager.get_password(
680 username_uq,realm_uq,("plain","md5:user:realm:pass"))
681 if pformat=="md5:user:realm:pass":
682 urp_hash=password
683 elif pformat=="plain":
684 urp_hash=_make_urp_hash(username,realm,password)
685 else:
686 self.__logger.debug("Couldn't get password.")
687 return Failure("not-authorized")
688 valid_response=_compute_response(urp_hash,self.nonce,cnonce,
689 nonce_count,authzid,digest_uri)
690 if response_val!=valid_response:
691 self.__logger.debug("Response mismatch: %r != %r" % (response_val,valid_response))
692 return Failure("not-authorized")
693 s=digest_uri_uq.split("/")
694 if len(s)==3:
695 serv_type,host,serv_name=s
696 elif len(s)==2:
697 serv_type,host=s
698 serv_name=None
699 else:
700 self.__logger.debug("Bad digest_uri: %r" % (digest_uri_uq,))
701 return Failure("not-authorized")
702 info={}
703 info["mechanism"]="DIGEST-MD5"
704 info["username"]=username_uq
705 info["serv-type"]=serv_type
706 info["host"]=host
707 info["serv-name"]=serv_name
708 if self.password_manager.check_authzid(authzid_uq,info):
709 rspauth=_compute_response_auth(urp_hash,self.nonce,
710 cnonce,nonce_count,authzid,digest_uri)
711 self.authzid=authzid
712 self.done=1
713 return Challenge("rspauth="+rspauth)
714 else:
715 self.__logger.debug("Authzid check failed")
716 return Failure("invalid_authzid")
717
718
719