comparison libervia/backend/core/patches.py @ 4237:a1e7e82a8921

core: implement SCRAM-SHA auth algorithm: Twisted auth mechanism are outdated, and as a result, Libervia was not supporting the mandatory SCRAM-SHA auth mechanism. This patch implements it for SCRAM-SHA-1, SCRAM-SHA-256 and SCRAM-SHA-512 variants.
author Goffi <goffi@goffi.org>
date Mon, 08 Apr 2024 12:29:40 +0200
parents 4b842c1fb686
children c14e904eee13
comparison
equal deleted inserted replaced
4236:f59e9421a650 4237:a1e7e82a8921
1 import base64
1 import copy 2 import copy
2 from twisted.words.protocols.jabber import xmlstream, sasl, client as tclient, jid 3 import secrets
4
5 from cryptography.hazmat.backends import default_backend
6 from cryptography.hazmat.primitives import hashes, hmac
7 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
8 from twisted.words.protocols.jabber import (
9 client as tclient,
10 jid,
11 sasl,
12 sasl_mechanisms,
13 xmlstream,
14 )
3 from wokkel import client 15 from wokkel import client
16 from zope.interface import implementer
17
4 from libervia.backend.core.constants import Const as C 18 from libervia.backend.core.constants import Const as C
5 from libervia.backend.core.log import getLogger 19 from libervia.backend.core.log import getLogger
6 20
7 log = getLogger(__name__) 21 log = getLogger(__name__)
8 22
11 (until merged upstream). 25 (until merged upstream).
12 Second part add a trigger point to send and onElement method of XmlStream 26 Second part add a trigger point to send and onElement method of XmlStream
13 """ 27 """
14 28
15 29
30 # SCRAM-SHA implementation
31
32
33 @implementer(sasl_mechanisms.ISASLMechanism)
34 class ScramSha:
35 """Implements the SCRAM-SHA SASL authentication mechanism.
36
37 This mechanism is defined in RFC 5802.
38 """
39
40 ALLOWED_ALGORITHMS = ("SHA-1", "SHA-256", "SHA-512")
41 backend = default_backend()
42
43 def __init__(self, username: str, password: str, algorithm: str) -> None:
44 """Initialize SCRAM-SHA mechanism with user credentials.
45
46 @param username: The user's username.
47 @param password: The user's password.
48 """
49 if algorithm not in self.ALLOWED_ALGORITHMS:
50 raise ValueError(f"Invalid algorithm: {algorithm!r}")
51
52 self.username = username
53 self.password = password
54 self.algorithm = getattr(hashes, algorithm.replace("-", "", 1))()
55 self.name = f"SCRAM-{algorithm}"
56 self.client_nonce = base64.b64encode(secrets.token_bytes(24)).decode()
57 self.server_nonce = None
58 self.salted_password = None
59
60 def digest(self, data: bytes) -> bytes:
61 hasher = hashes.Hash(self.algorithm)
62 hasher.update(data)
63 return hasher.finalize()
64
65 def _hmac(self, key: bytes, msg: bytes) -> bytes:
66 """Compute HMAC-SHA"""
67 h = hmac.HMAC(key, self.algorithm, backend=self.backend)
68 h.update(msg)
69 return h.finalize()
70
71 def _hi(self, password: str, salt: bytes, iterations: int) -> bytes:
72 kdf = PBKDF2HMAC(
73 algorithm=self.algorithm,
74 length=self.algorithm.digest_size,
75 salt=salt,
76 iterations=iterations,
77 backend=default_backend(),
78 )
79 return kdf.derive(password.encode())
80
81 def getInitialResponse(self) -> bytes:
82 """Builds the initial client response message."""
83 return f"n,,n={self.username},r={self.client_nonce}".encode()
84
85 def getResponse(self, challenge: bytes) -> bytes:
86 """SCRAM-SHA authentication final step. Building proof of having the password.
87
88 @param challenge: Challenge string from the server.
89 @return: Client proof.
90 """
91 challenge_parts = dict(item.split("=") for item in challenge.decode().split(","))
92 self.server_nonce = challenge_parts["r"]
93 salt = base64.b64decode(challenge_parts["s"])
94 iterations = int(challenge_parts["i"])
95 self.salted_password = self._hi(self.password, salt, iterations)
96
97 client_key = self._hmac(self.salted_password, b"Client Key")
98 stored_key = self.digest(client_key)
99 auth_message = (
100 f"n={self.username},r={self.client_nonce},{challenge.decode()},c=biws,"
101 f"r={self.server_nonce}"
102 ).encode()
103 client_signature = self._hmac(stored_key, auth_message)
104 client_proof = bytes(a ^ b for a, b in zip(client_key, client_signature))
105 client_final_message = (
106 f"c=biws,r={self.server_nonce},p={base64.b64encode(client_proof).decode()}"
107 )
108 return client_final_message.encode()
109
110
111 class SASLInitiatingInitializer(sasl.SASLInitiatingInitializer):
112
113 def setMechanism(self):
114 """
115 Select and setup authentication mechanism.
116
117 Uses the authenticator's C{jid} and C{password} attribute for the
118 authentication credentials. If no supported SASL mechanisms are
119 advertized by the receiving party, a failing deferred is returned with
120 a L{SASLNoAcceptableMechanism} exception.
121 """
122
123 jid = self.xmlstream.authenticator.jid
124 password = self.xmlstream.authenticator.password
125
126 mechanisms = sasl.get_mechanisms(self.xmlstream)
127 if jid.user is not None:
128 if "SCRAM-SHA-512" in mechanisms:
129 self.mechanism = ScramSha(jid.user, password, algorithm="SHA-512")
130 elif "SCRAM-SHA-256" in mechanisms:
131 self.mechanism = ScramSha(jid.user, password, algorithm="SHA-256")
132 elif "SCRAM-SHA-1" in mechanisms:
133 self.mechanism = ScramSha(jid.user, password, algorithm="SHA-1")
134 # FIXME: PLAIN should probably be disabled.
135 elif "PLAIN" in mechanisms:
136 self.mechanism = sasl_mechanisms.Plain(None, jid.user, password)
137 else:
138 raise sasl.SASLNoAcceptableMechanism()
139 else:
140 if "ANONYMOUS" in mechanisms:
141 self.mechanism = sasl_mechanisms.Anonymous()
142 else:
143 raise sasl.SASLNoAcceptableMechanism()
144
145
16 ## certificate validation patches 146 ## certificate validation patches
17 147
18 148
19 class XMPPClient(client.XMPPClient): 149 class XMPPClient(client.XMPPClient):
20 150
21 def __init__(self, jid, password, host=None, port=5222, 151 def __init__(
22 tls_required=True, configurationForTLS=None): 152 self,
153 jid,
154 password,
155 host=None,
156 port=5222,
157 tls_required=True,
158 configurationForTLS=None,
159 ):
23 self.jid = jid 160 self.jid = jid
24 self.domain = jid.host.encode('idna') 161 self.domain = jid.host.encode("idna")
25 self.host = host 162 self.host = host
26 self.port = port 163 self.port = port
27 164
28 factory = HybridClientFactory( 165 factory = HybridClientFactory(
29 jid, password, tls_required=tls_required, 166 jid,
30 configurationForTLS=configurationForTLS) 167 password,
168 tls_required=tls_required,
169 configurationForTLS=configurationForTLS,
170 )
31 171
32 client.StreamManager.__init__(self, factory) 172 client.StreamManager.__init__(self, factory)
33 173
34 174
35 def HybridClientFactory(jid, password, tls_required=True, configurationForTLS=None): 175 def HybridClientFactory(jid, password, tls_required=True, configurationForTLS=None):
50 190
51 def associateWithStream(self, xs): 191 def associateWithStream(self, xs):
52 xmlstream.ConnectAuthenticator.associateWithStream(self, xs) 192 xmlstream.ConnectAuthenticator.associateWithStream(self, xs)
53 193
54 tlsInit = xmlstream.TLSInitiatingInitializer( 194 tlsInit = xmlstream.TLSInitiatingInitializer(
55 xs, required=self.tls_required, configurationForTLS=self.configurationForTLS) 195 xs, required=self.tls_required, configurationForTLS=self.configurationForTLS
56 xs.initializers = [client.client.CheckVersionInitializer(xs), 196 )
57 tlsInit, 197 xs.initializers = [
58 CheckAuthInitializer(xs, self.res_binding)] 198 client.client.CheckVersionInitializer(xs),
199 tlsInit,
200 CheckAuthInitializer(xs, self.res_binding),
201 ]
59 202
60 203
61 # XmlStream triggers 204 # XmlStream triggers
62 205
63 206
109 252
110 def initialize(self): 253 def initialize(self):
111 # XXX: modification of client.CheckAuthInitializer which has optional 254 # XXX: modification of client.CheckAuthInitializer which has optional
112 # resource binding, and which doesn't do deprecated 255 # resource binding, and which doesn't do deprecated
113 # SessionInitializer 256 # SessionInitializer
114 if (sasl.NS_XMPP_SASL, 'mechanisms') in self.xmlstream.features: 257 if (sasl.NS_XMPP_SASL, "mechanisms") in self.xmlstream.features:
115 inits = [(sasl.SASLInitiatingInitializer, True)] 258 inits = [(SASLInitiatingInitializer, True)]
116 if self.res_binding: 259 if self.res_binding:
117 inits.append((tclient.BindInitializer, True)), 260 inits.append((tclient.BindInitializer, True)),
118 261
119 for initClass, required in inits: 262 for initClass, required in inits:
120 init = initClass(self.xmlstream) 263 init = initClass(self.xmlstream)
121 init.required = required 264 init.required = required
122 self.xmlstream.initializers.append(init) 265 self.xmlstream.initializers.append(init)
123 elif (tclient.NS_IQ_AUTH_FEATURE, 'auth') in self.xmlstream.features: 266 elif (tclient.NS_IQ_AUTH_FEATURE, "auth") in self.xmlstream.features:
124 self.xmlstream.initializers.append( 267 self.xmlstream.initializers.append(tclient.IQAuthInitializer(self.xmlstream))
125 tclient.IQAuthInitializer(self.xmlstream))
126 else: 268 else:
127 raise Exception("No available authentication method found") 269 raise Exception("No available authentication method found")
128 270
129 271
130 # jid fix 272 # jid fix
273
131 274
132 def internJID(jidstring): 275 def internJID(jidstring):
133 """ 276 """
134 Return interned JID. 277 Return interned JID.
135 278