about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/asyncpg/protocol/scram.pyx
diff options
context:
space:
mode:
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.pyx341
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