diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/asyncpg/protocol/scram.pyx')
-rw-r--r-- | .venv/lib/python3.12/site-packages/asyncpg/protocol/scram.pyx | 341 |
1 files changed, 341 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/asyncpg/protocol/scram.pyx b/.venv/lib/python3.12/site-packages/asyncpg/protocol/scram.pyx new file mode 100644 index 00000000..9b485aee --- /dev/null +++ b/.venv/lib/python3.12/site-packages/asyncpg/protocol/scram.pyx @@ -0,0 +1,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 |