-
Notifications
You must be signed in to change notification settings - Fork 391
/
scram.pyx
341 lines (295 loc) · 14.3 KB
/
scram.pyx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# Copyright (C) 2016-present the asyncpg authors and contributors
# <see AUTHORS file>
#
# This module is part of asyncpg and is released under
# the Apache 2.0 License: http://www.apache.org/licenses/LICENSE-2.0
import base64
import hashlib
import hmac
import re
import secrets
import stringprep
import unicodedata
@cython.final
cdef class SCRAMAuthentication:
"""Contains the protocol for generating and a SCRAM hashed password.
Since PostgreSQL 10, the option to hash passwords using the SCRAM-SHA-256
method was added. This module follows the defined protocol, which can be
referenced from here:
https://www.postgresql.org/docs/current/sasl-authentication.html#SASL-SCRAM-SHA-256
libpq references the following RFCs that it uses for implementation:
* RFC 5802
* RFC 5803
* RFC 7677
The protocol works as such:
- A client connets to the server. The server requests the client to begin
SASL authentication using SCRAM and presents a client with the methods it
supports. At present, those are SCRAM-SHA-256, and, on servers that are
built with OpenSSL and
are PG11+, SCRAM-SHA-256-PLUS (which supports channel binding, more on that
below)
- The client sends a "first message" to the server, where it chooses which
method to authenticate with, and sends, along with the method, an indication
of channel binding (we disable for now), a nonce, and the username.
(Technically, PostgreSQL ignores the username as it already has it from the
initical connection, but we add it for completeness)
- The server responds with a "first message" in which it extends the nonce,
as well as a password salt and the number of iterations to hash the password
with. The client validates that the new nonce contains the first part of the
client's original nonce
- The client generates a salted password, but does not sent this up to the
server. Instead, the client follows the SCRAM algorithm (RFC5802) to
generate a proof. This proof is sent aspart of a client "final message" to
the server for it to validate.
- The server validates the proof. If it is valid, the server sends a
verification code for the client to verify that the server came to the same
proof the client did. PostgreSQL immediately sends an AuthenticationOK
response right after a valid negotiation. If the password the client
provided was invalid, then authentication fails.
(The beauty of this is that the salted password is never transmitted over
the wire!)
PostgreSQL 11 added support for the channel binding (i.e.
SCRAM-SHA-256-PLUS) but to do some ongoing discussion, there is a conscious
decision by several driver authors to not support it as of yet. As such, the
channel binding parameter is hard-coded to "n" for now, but can be updated
to support other channel binding methos in the future
"""
AUTHENTICATION_METHODS = [b"SCRAM-SHA-256"]
DEFAULT_CLIENT_NONCE_BYTES = 24
DIGEST = hashlib.sha256
REQUIREMENTS_CLIENT_FINAL_MESSAGE = ['client_channel_binding',
'server_nonce']
REQUIREMENTS_CLIENT_PROOF = ['password_iterations', 'password_salt',
'server_first_message', 'server_nonce']
SASLPREP_PROHIBITED = (
stringprep.in_table_a1, # PostgreSQL treats this as prohibited
stringprep.in_table_c12,
stringprep.in_table_c21_c22,
stringprep.in_table_c3,
stringprep.in_table_c4,
stringprep.in_table_c5,
stringprep.in_table_c6,
stringprep.in_table_c7,
stringprep.in_table_c8,
stringprep.in_table_c9,
)
def __cinit__(self, bytes authentication_method):
self.authentication_method = authentication_method
self.authorization_message = None
# channel binding is turned off for the time being
self.client_channel_binding = b"n,,"
self.client_first_message_bare = None
self.client_nonce = None
self.client_proof = None
self.password_salt = None
# self.password_iterations = None
self.server_first_message = None
self.server_key = None
self.server_nonce = None
cdef create_client_first_message(self, str username):
"""Create the initial client message for SCRAM authentication"""
cdef:
bytes msg
bytes client_first_message
self.client_nonce = \
self._generate_client_nonce(self.DEFAULT_CLIENT_NONCE_BYTES)
# set the client first message bare here, as it's used in a later step
self.client_first_message_bare = b"n=" + username.encode("utf-8") + \
b",r=" + self.client_nonce
# put together the full message here
msg = bytes()
msg += self.authentication_method + b"\0"
client_first_message = self.client_channel_binding + \
self.client_first_message_bare
msg += (len(client_first_message)).to_bytes(4, byteorder='big') + \
client_first_message
return msg
cdef create_client_final_message(self, str password):
"""Create the final client message as part of SCRAM authentication"""
cdef:
bytes msg
if any([getattr(self, val) is None for val in
self.REQUIREMENTS_CLIENT_FINAL_MESSAGE]):
raise Exception(
"you need values from server to generate a client proof")
# normalize the password using the SASLprep algorithm in RFC 4013
password = self._normalize_password(password)
# generate the client proof
self.client_proof = self._generate_client_proof(password=password)
msg = bytes()
msg += b"c=" + base64.b64encode(self.client_channel_binding) + \
b",r=" + self.server_nonce + \
b",p=" + base64.b64encode(self.client_proof)
return msg
cdef parse_server_first_message(self, bytes server_response):
"""Parse the response from the first message from the server"""
self.server_first_message = server_response
try:
self.server_nonce = re.search(b'r=([^,]+),',
self.server_first_message).group(1)
except IndexError:
raise Exception("could not get nonce")
if not self.server_nonce.startswith(self.client_nonce):
raise Exception("invalid nonce")
try:
self.password_salt = re.search(b's=([^,]+),',
self.server_first_message).group(1)
except IndexError:
raise Exception("could not get salt")
try:
self.password_iterations = int(re.search(b'i=(\d+),?',
self.server_first_message).group(1))
except (IndexError, TypeError, ValueError):
raise Exception("could not get iterations")
cdef verify_server_final_message(self, bytes server_final_message):
"""Verify the final message from the server"""
cdef:
bytes server_signature
try:
server_signature = re.search(b'v=([^,]+)',
server_final_message).group(1)
except IndexError:
raise Exception("could not get server signature")
verify_server_signature = hmac.new(self.server_key.digest(),
self.authorization_message, self.DIGEST)
# validate the server signature against the verifier
return server_signature == base64.b64encode(
verify_server_signature.digest())
cdef _bytes_xor(self, bytes a, bytes b):
"""XOR two bytestrings together"""
return bytes(a_i ^ b_i for a_i, b_i in zip(a, b))
cdef _generate_client_nonce(self, int num_bytes):
cdef:
bytes token
token = secrets.token_bytes(num_bytes)
return base64.b64encode(token)
cdef _generate_client_proof(self, str password):
"""need to ensure a server response exists, i.e. """
cdef:
bytes salted_password
if any([getattr(self, val) is None for val in
self.REQUIREMENTS_CLIENT_PROOF]):
raise Exception(
"you need values from server to generate a client proof")
# generate a salt password
salted_password = self._generate_salted_password(password,
self.password_salt, self.password_iterations)
# client key is derived from the salted password
client_key = hmac.new(salted_password, b"Client Key", self.DIGEST)
# this allows us to compute the stored key that is residing on the server
stored_key = self.DIGEST(client_key.digest())
# as well as compute the server key
self.server_key = hmac.new(salted_password, b"Server Key", self.DIGEST)
# build the authorization message that will be used in the
# client signature
# the "c=" portion is for the channel binding, but this is not
# presently implemented
self.authorization_message = self.client_first_message_bare + b"," + \
self.server_first_message + b",c=" + \
base64.b64encode(self.client_channel_binding) + \
b",r=" + self.server_nonce
# sign!
client_signature = hmac.new(stored_key.digest(),
self.authorization_message, self.DIGEST)
# and the proof
return self._bytes_xor(client_key.digest(), client_signature.digest())
cdef _generate_salted_password(self, str password, bytes salt, int iterations):
"""This follows the "Hi" algorithm specified in RFC5802"""
cdef:
bytes p
bytes s
bytes u
# convert the password to a binary string - UTF8 is safe for SASL
# (though there are SASLPrep rules)
p = password.encode("utf8")
# the salt needs to be base64 decoded -- full binary must be used
s = base64.b64decode(salt)
# the initial signature is the salt with a terminator of a 32-bit string
# ending in 1
ui = hmac.new(p, s + b'\x00\x00\x00\x01', self.DIGEST)
# grab the initial digest
u = ui.digest()
# for X number of iterations, recompute the HMAC signature against the
# password and the latest iteration of the hash, and XOR it with the
# previous version
for x in range(iterations - 1):
ui = hmac.new(p, ui.digest(), hashlib.sha256)
# this is a fancy way of XORing two byte strings together
u = self._bytes_xor(u, ui.digest())
return u
cdef _normalize_password(self, str original_password):
"""Normalize the password using the SASLprep from RFC4013"""
cdef:
str normalized_password
# Note: Per the PostgreSQL documentation, PostgreSWL does not require
# UTF-8 to be used for the password, but will perform SASLprep on the
# password regardless.
# If the password is not valid UTF-8, PostgreSQL will then **not** use
# SASLprep processing.
# If the password fails SASLprep, the password should still be sent
# See: https://www.postgresql.org/docs/current/sasl-authentication.html
# and
# https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/common/saslprep.c
# using the `pg_saslprep` function
normalized_password = original_password
# if the original password is an ASCII string or fails to encode as a
# UTF-8 string, then no further action is needed
try:
original_password.encode("ascii")
except UnicodeEncodeError:
pass
else:
return original_password
# Step 1 of SASLPrep: Map. Per the algorithm, we map non-ascii space
# characters to ASCII spaces (\x20 or \u0020, but we will use ' ') and
# commonly mapped to nothing characters are removed
# Table C.1.2 -- non-ASCII spaces
# Table B.1 -- "Commonly mapped to nothing"
normalized_password = u"".join(
' ' if stringprep.in_table_c12(c) else c
for c in tuple(normalized_password) if not stringprep.in_table_b1(c)
)
# If at this point the password is empty, PostgreSQL uses the original
# password
if not normalized_password:
return original_password
# Step 2 of SASLPrep: Normalize. Normalize the password using the
# Unicode normalization algorithm to NFKC form
normalized_password = unicodedata.normalize('NFKC', normalized_password)
# If the password is not empty, PostgreSQL uses the original password
if not normalized_password:
return original_password
normalized_password_tuple = tuple(normalized_password)
# Step 3 of SASLPrep: Prohobited characters. If PostgreSQL detects any
# of the prohibited characters in SASLPrep, it will use the original
# password
# We also include "unassigned code points" in the prohibited character
# category as PostgreSQL does the same
for c in normalized_password_tuple:
if any(
in_prohibited_table(c)
for in_prohibited_table in self.SASLPREP_PROHIBITED
):
return original_password
# Step 4 of SASLPrep: Bi-directional characters. PostgreSQL follows the
# rules for bi-directional characters laid on in RFC3454 Sec. 6 which
# are:
# 1. Characters in RFC 3454 Sec 5.8 are prohibited (C.8)
# 2. If a string contains a RandALCat character, it cannot containy any
# LCat character
# 3. If the string contains any RandALCat character, an RandALCat
# character must be the first and last character of the string
# RandALCat characters are found in table D.1, whereas LCat are in D.2
if any(stringprep.in_table_d1(c) for c in normalized_password_tuple):
# if the first character or the last character are not in D.1,
# return the original password
if not (stringprep.in_table_d1(normalized_password_tuple[0]) and
stringprep.in_table_d1(normalized_password_tuple[-1])):
return original_password
# if any characters are in D.2, use the original password
if any(
stringprep.in_table_d2(c) for c in normalized_password_tuple
):
return original_password
# return the normalized password
return normalized_password