sasl_mechanisms.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. # -*- test-case-name: twisted.words.test.test_jabbersaslmechanisms -*-
  2. #
  3. # Copyright (c) Twisted Matrix Laboratories.
  4. # See LICENSE for details.
  5. """
  6. Protocol agnostic implementations of SASL authentication mechanisms.
  7. """
  8. from __future__ import absolute_import, division
  9. import binascii, random, time, os
  10. from hashlib import md5
  11. from zope.interface import Interface, Attribute, implementer
  12. from twisted.python.compat import iteritems, networkString
  13. class ISASLMechanism(Interface):
  14. name = Attribute("""Common name for the SASL Mechanism.""")
  15. def getInitialResponse():
  16. """
  17. Get the initial client response, if defined for this mechanism.
  18. @return: initial client response string.
  19. @rtype: C{str}.
  20. """
  21. def getResponse(challenge):
  22. """
  23. Get the response to a server challenge.
  24. @param challenge: server challenge.
  25. @type challenge: C{str}.
  26. @return: client response.
  27. @rtype: C{str}.
  28. """
  29. @implementer(ISASLMechanism)
  30. class Anonymous(object):
  31. """
  32. Implements the ANONYMOUS SASL authentication mechanism.
  33. This mechanism is defined in RFC 2245.
  34. """
  35. name = 'ANONYMOUS'
  36. def getInitialResponse(self):
  37. return None
  38. @implementer(ISASLMechanism)
  39. class Plain(object):
  40. """
  41. Implements the PLAIN SASL authentication mechanism.
  42. The PLAIN SASL authentication mechanism is defined in RFC 2595.
  43. """
  44. name = 'PLAIN'
  45. def __init__(self, authzid, authcid, password):
  46. """
  47. @param authzid: The authorization identity.
  48. @type authzid: L{unicode}
  49. @param authcid: The authentication identity.
  50. @type authcid: L{unicode}
  51. @param password: The plain-text password.
  52. @type password: L{unicode}
  53. """
  54. self.authzid = authzid or u''
  55. self.authcid = authcid or u''
  56. self.password = password or u''
  57. def getInitialResponse(self):
  58. return (self.authzid.encode('utf-8') + b"\x00" +
  59. self.authcid.encode('utf-8') + b"\x00" +
  60. self.password.encode('utf-8'))
  61. @implementer(ISASLMechanism)
  62. class DigestMD5(object):
  63. """
  64. Implements the DIGEST-MD5 SASL authentication mechanism.
  65. The DIGEST-MD5 SASL authentication mechanism is defined in RFC 2831.
  66. """
  67. name = 'DIGEST-MD5'
  68. def __init__(self, serv_type, host, serv_name, username, password):
  69. """
  70. @param serv_type: An indication of what kind of server authentication
  71. is being attempted against. For example, C{u"xmpp"}.
  72. @type serv_type: C{unicode}
  73. @param host: The authentication hostname. Also known as the realm.
  74. This is used as a scope to help select the right credentials.
  75. @type host: C{unicode}
  76. @param serv_name: An additional identifier for the server.
  77. @type serv_name: C{unicode}
  78. @param username: The authentication username to use to respond to a
  79. challenge.
  80. @type username: C{unicode}
  81. @param username: The authentication password to use to respond to a
  82. challenge.
  83. @type password: C{unicode}
  84. """
  85. self.username = username
  86. self.password = password
  87. self.defaultRealm = host
  88. self.digest_uri = u'%s/%s' % (serv_type, host)
  89. if serv_name is not None:
  90. self.digest_uri += u'/%s' % (serv_name,)
  91. def getInitialResponse(self):
  92. return None
  93. def getResponse(self, challenge):
  94. directives = self._parse(challenge)
  95. # Compat for implementations that do not send this along with
  96. # a successful authentication.
  97. if b'rspauth' in directives:
  98. return b''
  99. charset = directives[b'charset'].decode('ascii')
  100. try:
  101. realm = directives[b'realm']
  102. except KeyError:
  103. realm = self.defaultRealm.encode(charset)
  104. return self._genResponse(charset,
  105. realm,
  106. directives[b'nonce'])
  107. def _parse(self, challenge):
  108. """
  109. Parses the server challenge.
  110. Splits the challenge into a dictionary of directives with values.
  111. @return: challenge directives and their values.
  112. @rtype: C{dict} of C{str} to C{str}.
  113. """
  114. s = challenge
  115. paramDict = {}
  116. cur = 0
  117. remainingParams = True
  118. while remainingParams:
  119. # Parse a param. We can't just split on commas, because there can
  120. # be some commas inside (quoted) param values, e.g.:
  121. # qop="auth,auth-int"
  122. middle = s.index(b"=", cur)
  123. name = s[cur:middle].lstrip()
  124. middle += 1
  125. if s[middle:middle+1] == b'"':
  126. middle += 1
  127. end = s.index(b'"', middle)
  128. value = s[middle:end]
  129. cur = s.find(b',', end) + 1
  130. if cur == 0:
  131. remainingParams = False
  132. else:
  133. end = s.find(b',', middle)
  134. if end == -1:
  135. value = s[middle:].rstrip()
  136. remainingParams = False
  137. else:
  138. value = s[middle:end].rstrip()
  139. cur = end + 1
  140. paramDict[name] = value
  141. for param in (b'qop', b'cipher'):
  142. if param in paramDict:
  143. paramDict[param] = paramDict[param].split(b',')
  144. return paramDict
  145. def _unparse(self, directives):
  146. """
  147. Create message string from directives.
  148. @param directives: dictionary of directives (names to their values).
  149. For certain directives, extra quotes are added, as
  150. needed.
  151. @type directives: C{dict} of C{str} to C{str}
  152. @return: message string.
  153. @rtype: C{str}.
  154. """
  155. directive_list = []
  156. for name, value in iteritems(directives):
  157. if name in (b'username', b'realm', b'cnonce',
  158. b'nonce', b'digest-uri', b'authzid', b'cipher'):
  159. directive = name + b'=' + value
  160. else:
  161. directive = name + b'=' + value
  162. directive_list.append(directive)
  163. return b','.join(directive_list)
  164. def _calculateResponse(self, cnonce, nc, nonce,
  165. username, password, realm, uri):
  166. """
  167. Calculates response with given encoded parameters.
  168. @return: The I{response} field of a response to a Digest-MD5 challenge
  169. of the given parameters.
  170. @rtype: L{bytes}
  171. """
  172. def H(s):
  173. return md5(s).digest()
  174. def HEX(n):
  175. return binascii.b2a_hex(n)
  176. def KD(k, s):
  177. return H(k + b':' + s)
  178. a1 = (H(username + b":" + realm + b":" + password) + b":" +
  179. nonce + b":" +
  180. cnonce)
  181. a2 = b"AUTHENTICATE:" + uri
  182. response = HEX(KD(HEX(H(a1)),
  183. nonce + b":" + nc + b":" + cnonce + b":" +
  184. b"auth" + b":" + HEX(H(a2))))
  185. return response
  186. def _genResponse(self, charset, realm, nonce):
  187. """
  188. Generate response-value.
  189. Creates a response to a challenge according to section 2.1.2.1 of
  190. RFC 2831 using the C{charset}, C{realm} and C{nonce} directives
  191. from the challenge.
  192. """
  193. try:
  194. username = self.username.encode(charset)
  195. password = self.password.encode(charset)
  196. digest_uri = self.digest_uri.encode(charset)
  197. except UnicodeError:
  198. # TODO - add error checking
  199. raise
  200. nc = networkString('%08x' % (1,)) # TODO: support subsequent auth.
  201. cnonce = self._gen_nonce()
  202. qop = b'auth'
  203. # TODO - add support for authzid
  204. response = self._calculateResponse(cnonce, nc, nonce,
  205. username, password, realm,
  206. digest_uri)
  207. directives = {b'username': username,
  208. b'realm' : realm,
  209. b'nonce' : nonce,
  210. b'cnonce' : cnonce,
  211. b'nc' : nc,
  212. b'qop' : qop,
  213. b'digest-uri': digest_uri,
  214. b'response': response,
  215. b'charset': charset.encode('ascii')}
  216. return self._unparse(directives)
  217. def _gen_nonce(self):
  218. nonceString = "%f:%f:%d" % (random.random(), time.time(), os.getpid())
  219. nonceBytes = networkString(nonceString)
  220. return md5(nonceBytes).hexdigest().encode('ascii')