scramp-1.4.5/.github/workflows/test.yml0000644000000000000000000000234013615410400015024 0ustar00name: scramp permissions: read-all on: [push] jobs: pytest: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] container: python:${{ matrix.python-version }} steps: - uses: actions/checkout@v4 - name: Install dependencies run: | # Add safe directory to work around bug in GitHub actions/checkout git config --global --add safe.directory ${GITHUB_WORKSPACE} python -m pip install --upgrade pip pip install pytest pytest-mock . - name: Run pytest tests run: | python -m pytest -x -v -W error test --ignore=test/test_readme.py checks: runs-on: ubuntu-latest steps: - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.10" - uses: actions/checkout@v4 - name: Install dependencies run: | python -m pip install --upgrade pip pip install build flake8 Flake8-pyproject flake8-alphabetize pytest black \ passlib twine . - name: Run checks run: | black --check . flake8 . python -m pytest -x -v -W error test/test_readme.py scramp-1.4.5/src/scramp/__init__.py0000644000000000000000000000040713615410400014114 0ustar00from importlib.metadata import version from scramp.core import ( ScramClient, ScramException, ScramMechanism, make_channel_binding, ) __all__ = [ScramClient, ScramMechanism, ScramException, make_channel_binding] __version__ = version("scramp") scramp-1.4.5/src/scramp/core.py0000644000000000000000000005374513615410400013322 0ustar00import hashlib import unicodedata from enum import IntEnum, unique from functools import wraps from operator import attrgetter from os import urandom from stringprep import ( in_table_a1, in_table_b1, in_table_c12, in_table_c21_c22, in_table_c3, in_table_c4, in_table_c5, in_table_c6, in_table_c7, in_table_c8, in_table_c9, in_table_d1, in_table_d2, ) from uuid import uuid4 from asn1crypto.x509 import Certificate from scramp.utils import b64dec, b64enc, h, hi, hmac, uenc, xor # https://tools.ietf.org/html/rfc5802 # https://www.rfc-editor.org/rfc/rfc7677.txt @unique class ClientStage(IntEnum): get_client_first = 1 set_server_first = 2 get_client_final = 3 set_server_final = 4 @unique class ServerStage(IntEnum): set_client_first = 1 get_server_first = 2 set_client_final = 3 get_server_final = 4 def _check_stage(Stages, current_stage, next_stage): if current_stage is None: if next_stage != 1: raise ScramException(f"The method {Stages(1).name} must be called first.") elif current_stage == 4: raise ScramException("The authentication sequence has already finished.") elif next_stage != current_stage + 1: raise ScramException( f"The next method to be called is " f"{Stages(current_stage + 1).name}, not this method." ) class ScramException(Exception): def __init__(self, message, server_error=None): super().__init__(message) self.server_error = server_error def __str__(self): s_str = "" if self.server_error is None else f": {self.server_error}" return super().__str__() + s_str MECHANISMS = ( "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-256-PLUS", "SCRAM-SHA-512", "SCRAM-SHA-512-PLUS", "SCRAM-SHA3-512", "SCRAM-SHA3-512-PLUS", ) CHANNEL_TYPES = ( "tls-server-end-point", "tls-unique", "tls-unique-for-telnet", ) def _make_cb_data(name, ssl_socket): if name == "tls-unique": return ssl_socket.get_channel_binding(name) elif name == "tls-server-end-point": cert_bin = ssl_socket.getpeercert(binary_form=True) cert = Certificate.load(cert_bin) # Find the hash algorithm to use according to # https://tools.ietf.org/html/rfc5929#section-4 hash_algo = cert.hash_algo if hash_algo in ("md5", "sha1"): hash_algo = "sha256" try: hash_obj = hashlib.new(hash_algo, cert_bin) except ValueError as e: raise ScramException( f"Hash algorithm {hash_algo} not supported by hashlib. {e}" ) return hash_obj.digest() else: raise ScramException(f"Channel binding name {name} not recognized.") def make_channel_binding(name, ssl_socket): return name, _make_cb_data(name, ssl_socket) class ScramMechanism: MECH_LOOKUP = { "SCRAM-SHA-1": (hashlib.sha1, False, 4096, 0), "SCRAM-SHA-1-PLUS": (hashlib.sha1, True, 4096, 1), "SCRAM-SHA-256": (hashlib.sha256, False, 4096, 2), "SCRAM-SHA-256-PLUS": (hashlib.sha256, True, 4096, 3), "SCRAM-SHA-512": (hashlib.sha512, False, 4096, 4), "SCRAM-SHA-512-PLUS": (hashlib.sha512, True, 4096, 5), "SCRAM-SHA3-512": (hashlib.sha3_512, False, 10000, 6), "SCRAM-SHA3-512-PLUS": (hashlib.sha3_512, True, 10000, 7), } def __init__(self, mechanism="SCRAM-SHA-256"): if mechanism not in MECHANISMS: raise ScramException( f"The mechanism name '{mechanism}' is not supported. The " f"supported mechanisms are {MECHANISMS}." ) self.name = mechanism ( self.hf, self.use_binding, self.iteration_count, self.strength, ) = self.MECH_LOOKUP[mechanism] def make_auth_info(self, password, iteration_count=None, salt=None): if iteration_count is None: iteration_count = self.iteration_count salt, stored_key, server_key = _make_auth_info( self.hf, password, iteration_count, salt=salt ) return salt, stored_key, server_key, iteration_count def make_stored_server_keys(self, salted_password): _, stored_key, server_key = _c_key_stored_key_s_key(self.hf, salted_password) return stored_key, server_key def make_server(self, auth_fn, channel_binding=None, s_nonce=None): return ScramServer( self, auth_fn, channel_binding=channel_binding, s_nonce=s_nonce ) def _make_auth_info(hf, password, i, salt=None): if salt is None: salt = urandom(16) salted_password = _make_salted_password(hf, password, salt, i) _, stored_key, server_key = _c_key_stored_key_s_key(hf, salted_password) return salt, stored_key, server_key def _validate_channel_binding(channel_binding): if channel_binding is None: return if not isinstance(channel_binding, tuple): raise ScramException( "The channel_binding parameter must either be None or a tuple." ) if len(channel_binding) != 2: raise ScramException( "The channel_binding parameter must either be None or a tuple of two " "elements (type, data)." ) channel_type, channel_data = channel_binding if channel_type not in CHANNEL_TYPES: raise ScramException( "The channel_binding parameter must either be None or a tuple with the " "first element a str specifying one of the channel types {CHANNEL_TYPES}." ) if not isinstance(channel_data, bytes): raise ScramException( "The channel_binding parameter must either be None or a tuple with the " "second element a bytes object containing the bind data." ) class ScramClient: def __init__( self, mechanisms, username, password, channel_binding=None, c_nonce=None ): if not isinstance(mechanisms, (list, tuple)): raise ScramException( "The 'mechanisms' parameter must be a list or tuple of mechanism names." ) _validate_channel_binding(channel_binding) ms = (ScramMechanism(m) for m in mechanisms) mechs = [m for m in ms if not (channel_binding is None and m.use_binding)] if len(mechs) == 0: raise ScramException( f"There are no suitable mechanisms in the list provided: {mechanisms}" ) mech = sorted(mechs, key=attrgetter("strength"))[-1] self.hf, self.use_binding = mech.hf, mech.use_binding self.mechanism_name = mech.name self.c_nonce = _make_nonce() if c_nonce is None else c_nonce self.username = username self.password = password self.channel_binding = channel_binding self.stage = None def _set_stage(self, next_stage): _check_stage(ClientStage, self.stage, next_stage) self.stage = next_stage def get_client_first(self): self._set_stage(ClientStage.get_client_first) self.client_first_bare, client_first = _get_client_first( self.username, self.c_nonce, self.channel_binding, self.use_binding ) return client_first def set_server_first(self, message): self._set_stage(ClientStage.set_server_first) self.server_first = message self.nonce, self.salt, self.iterations = _set_server_first( message, self.c_nonce ) def get_client_final(self): self._set_stage(ClientStage.get_client_final) self.server_signature, cfinal = _get_client_final( self.hf, self.password, self.salt, self.iterations, self.nonce, self.client_first_bare, self.server_first, self.channel_binding, self.use_binding, ) return cfinal def set_server_final(self, message): self._set_stage(ClientStage.set_server_final) _set_server_final(message, self.server_signature) def set_error(f): @wraps(f) def wrapper(self, *args, **kwds): try: return f(self, *args, **kwds) except ScramException as e: if e.server_error is not None: self.error = e.server_error self.stage = ServerStage.set_client_final raise e return wrapper class ScramServer: def __init__(self, mechanism, auth_fn, channel_binding=None, s_nonce=None): _validate_channel_binding(channel_binding) self.channel_binding = channel_binding self.s_nonce = _make_nonce() if s_nonce is None else s_nonce self.auth_fn = auth_fn self.stage = None self.server_signature = None self.error = None self._set_mechanism(mechanism) def _set_mechanism(self, mechanism): if mechanism.use_binding and self.channel_binding is None: raise ScramException( "The mechanism requires channel binding, and so channel_binding can't " "be None." ) self.m = mechanism def _set_stage(self, next_stage): _check_stage(ServerStage, self.stage, next_stage) self.stage = next_stage @set_error def set_client_first(self, client_first): self._set_stage(ServerStage.set_client_first) ( self.nonce, self.user, self.client_first_bare, upgrade_mechanism, ) = _set_client_first( client_first, self.s_nonce, self.channel_binding, self.m.use_binding ) if upgrade_mechanism: mech = ScramMechanism(f"{self.m.name}-PLUS") self._set_mechanism(mech) salt, self.stored_key, self.server_key, self.i = self.auth_fn(self.user) self.salt = b64enc(salt) @set_error def get_server_first(self): self._set_stage(ServerStage.get_server_first) self.server_first = _get_server_first( self.nonce, self.salt, self.i, ) return self.server_first @set_error def set_client_final(self, client_final): self._set_stage(ServerStage.set_client_final) self.server_signature = _set_client_final( self.m.hf, client_final, self.s_nonce, self.stored_key, self.server_key, self.client_first_bare, self.server_first, self.channel_binding, self.m.use_binding, ) @set_error def get_server_final(self): self._set_stage(ServerStage.get_server_final) return _get_server_final(self.server_signature, self.error) def _make_nonce(): return str(uuid4()).replace("-", "") def _make_auth_message(client_first_bare, server_first, client_final_without_proof): msg = client_first_bare, server_first, client_final_without_proof return uenc(",".join(msg)) def _make_salted_password(hf, password, salt, iterations): return hi(hf, uenc(saslprep(password)), salt, iterations) def _c_key_stored_key_s_key(hf, salted_password): client_key = hmac(hf, salted_password, b"Client Key") stored_key = h(hf, client_key) server_key = hmac(hf, salted_password, b"Server Key") return client_key, stored_key, server_key def _check_client_key(hf, stored_key, auth_msg, proof): client_signature = hmac(hf, stored_key, auth_msg) client_key = xor(client_signature, b64dec(proof)) key = h(hf, client_key) if key != stored_key: raise ScramException("The client keys don't match.", SERVER_ERROR_INVALID_PROOF) def _make_gs2_header(channel_binding, use_binding): if channel_binding is None: return "n", "n,," else: if use_binding: channel_type, _ = channel_binding return "p", f"p={channel_type},," else: return "y", "y,," def _make_cbind_input(channel_binding, use_binding): gs2_cbind_flag, gs2_header = _make_gs2_header(channel_binding, use_binding) gs2_header_bin = gs2_header.encode("ascii") if gs2_cbind_flag in ("y", "n"): return gs2_header_bin elif gs2_cbind_flag == "p": _, cbind_data = channel_binding return gs2_header_bin + cbind_data else: raise ScramException(f"The gs2_cbind_flag '{gs2_cbind_flag}' is not recognized") def _parse_message(msg, desc, *validations): m = {} for p in msg.split(","): if len(p) < 2 or p[1] != "=": raise ScramException( f"Malformed {desc} message. Attributes must be separated by a ',' and " f"each attribute must start with a letter followed by a '='", SERVER_ERROR_OTHER_ERROR, ) m[p[0]] = p[2:] m = {e[0]: e[2:] for e in msg.split(",")} keystr = "".join(m.keys()) for validation in validations: if keystr == validation: return m if len(validations) == 1: val_str = f"'{validations[0]}'" else: val_str = f"one of {validations}" raise ScramException( f"Malformed {desc} message. Expected the attribute list to be {val_str} but " f"found '{keystr}'", SERVER_ERROR_OTHER_ERROR, ) def _get_client_first(username, c_nonce, channel_binding, use_binding): try: u = saslprep(username) except ScramException as e: raise ScramException(e.args[0], SERVER_ERROR_INVALID_USERNAME_ENCODING) bare = ",".join((f"n={u}", f"r={c_nonce}")) _, gs2_header = _make_gs2_header(channel_binding, use_binding) return bare, gs2_header + bare def _set_client_first(client_first, s_nonce, channel_binding, use_binding): try: first_comma = client_first.index(",") second_comma = client_first.index(",", first_comma + 1) except ValueError: raise ScramException( "The client sent a malformed first message", SERVER_ERROR_OTHER_ERROR, ) gs2_header = client_first[:second_comma].split(",") try: gs2_cbind_flag = gs2_header[0] gs2_char = gs2_cbind_flag[0] except IndexError: raise ScramException( "The client sent malformed gs2 data", SERVER_ERROR_OTHER_ERROR, ) upgrade_mechanism = False if gs2_char == "y": if channel_binding is not None: raise ScramException( "Recieved GS2 flag 'y' which indicates that the client doesn't think " "the server supports channel binding, but in fact it does", SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING, ) elif gs2_char == "n": if use_binding: raise ScramException( "Received GS2 flag 'n' which indicates that the client doesn't require " "channel binding, but the server does", SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING, ) elif gs2_char == "p": if channel_binding is None: raise ScramException( "Received GS2 flag 'p' which indicates that the client requires " "channel binding, but the server does not", SERVER_ERROR_CHANNEL_BINDING_NOT_SUPPORTED, ) if not use_binding: upgrade_mechanism = True channel_type, _ = channel_binding cb_name = gs2_cbind_flag.split("=")[-1] if cb_name != channel_type: raise ScramException( f"Received channel binding name {cb_name} but this server supports the " f"channel binding name {channel_type}", SERVER_ERROR_UNSUPPORTED_CHANNEL_BINDING_TYPE, ) else: raise ScramException( f"Received GS2 flag {gs2_char} which isn't recognized", SERVER_ERROR_OTHER_ERROR, ) client_first_bare = client_first[second_comma + 1 :] msg = _parse_message(client_first_bare, "client first bare", "nr") c_nonce = msg["r"] nonce = c_nonce + s_nonce user = msg["n"] return nonce, user, client_first_bare, upgrade_mechanism def _get_server_first(nonce, salt, iterations): return ",".join((f"r={nonce}", f"s={salt}", f"i={iterations}")) def _set_server_first(server_first, c_nonce): msg = _parse_message(server_first, "server first", "rsi") if "e" in msg: raise ScramException(f"The server returned the error: {msg['e']}") nonce = msg["r"] salt = msg["s"] iterations = int(msg["i"]) if not nonce.startswith(c_nonce): raise ScramException("Client nonce doesn't match.", SERVER_ERROR_OTHER_ERROR) return nonce, salt, iterations def _get_client_final( hf, password, salt_str, iterations, nonce, client_first_bare, server_first, channel_binding, use_binding, ): salt = b64dec(salt_str) salted_password = _make_salted_password(hf, password, salt, iterations) client_key, stored_key, server_key = _c_key_stored_key_s_key(hf, salted_password) cbind_input = _make_cbind_input(channel_binding, use_binding) client_final_without_proof = f"c={b64enc(cbind_input)},r={nonce}" auth_msg = _make_auth_message( client_first_bare, server_first, client_final_without_proof ) client_signature = hmac(hf, stored_key, auth_msg) client_proof = xor(client_key, client_signature) server_signature = hmac(hf, server_key, auth_msg) client_final = f"{client_final_without_proof},p={b64enc(client_proof)}" return b64enc(server_signature), client_final SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" SERVER_ERROR_EXTENSIONS_NOT_SUPPORTED = "extensions-not-supported" SERVER_ERROR_INVALID_PROOF = "invalid-proof" SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH = "channel-bindings-dont-match" SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING = "server-does-support-channel-binding" SERVER_ERROR_SERVER_DOES_NOT_SUPPORT_CHANNEL_BINDING = ( "server does not support channel binding" ) SERVER_ERROR_CHANNEL_BINDING_NOT_SUPPORTED = "channel-binding-not-supported" SERVER_ERROR_UNSUPPORTED_CHANNEL_BINDING_TYPE = "unsupported-channel-binding-type" SERVER_ERROR_UNKNOWN_USER = "unknown-user" SERVER_ERROR_INVALID_USERNAME_ENCODING = "invalid-username-encoding" SERVER_ERROR_NO_RESOURCES = "no-resources" SERVER_ERROR_OTHER_ERROR = "other-error" def _set_client_final( hf, client_final, s_nonce, stored_key, server_key, client_first_bare, server_first, channel_binding, use_binding, ): msg = _parse_message(client_final, "client final", "crp") chan_binding = msg["c"] nonce = msg["r"] proof = msg["p"] if use_binding and b64dec(chan_binding) != _make_cbind_input( channel_binding, use_binding ): raise ScramException( "The channel bindings don't match.", SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH, ) if not nonce.endswith(s_nonce): raise ScramException("Server nonce doesn't match.", SERVER_ERROR_OTHER_ERROR) client_final_without_proof = f"c={chan_binding},r={nonce}" auth_msg = _make_auth_message( client_first_bare, server_first, client_final_without_proof ) _check_client_key(hf, stored_key, auth_msg, proof) sig = hmac(hf, server_key, auth_msg) return b64enc(sig) def _get_server_final(server_signature, error): return f"v={server_signature}" if error is None else f"e={error}" def _set_server_final(message, server_signature): msg = _parse_message(message, "server final", "v", "e") if "e" in msg: raise ScramException(f"The server returned the error: {msg['e']}") if server_signature != msg["v"]: raise ScramException( "The server signature doesn't match.", SERVER_ERROR_OTHER_ERROR ) def saslprep(source): # mapping stage # - map non-ascii spaces to U+0020 (stringprep C.1.2) # - strip 'commonly mapped to nothing' chars (stringprep B.1) data = "".join(" " if in_table_c12(c) else c for c in source if not in_table_b1(c)) # normalize to KC form data = unicodedata.normalize("NFKC", data) if not data: return "" # check for invalid bi-directional strings. # stringprep requires the following: # - chars in C.8 must be prohibited. # - if any R/AL chars in string: # - no L chars allowed in string # - first and last must be R/AL chars # this checks if start/end are R/AL chars. if so, prohibited loop # will forbid all L chars. if not, prohibited loop will forbid all # R/AL chars instead. in both cases, prohibited loop takes care of C.8. is_ral_char = in_table_d1 if is_ral_char(data[0]): if not is_ral_char(data[-1]): raise ScramException( "malformed bidi sequence", SERVER_ERROR_INVALID_ENCODING ) # forbid L chars within R/AL sequence. is_forbidden_bidi_char = in_table_d2 else: # forbid R/AL chars if start not setup correctly; L chars allowed. is_forbidden_bidi_char = is_ral_char # check for prohibited output # stringprep tables A.1, B.1, C.1.2, C.2 - C.9 for c in data: # check for chars mapping stage should have removed assert not in_table_b1(c), "failed to strip B.1 in mapping stage" assert not in_table_c12(c), "failed to replace C.1.2 in mapping stage" # check for forbidden chars for f, msg in ( (in_table_a1, "unassigned code points forbidden"), (in_table_c21_c22, "control characters forbidden"), (in_table_c3, "private use characters forbidden"), (in_table_c4, "non-char code points forbidden"), (in_table_c5, "surrogate codes forbidden"), (in_table_c6, "non-plaintext chars forbidden"), (in_table_c7, "non-canonical chars forbidden"), (in_table_c8, "display-modifying/deprecated chars forbidden"), (in_table_c9, "tagged characters forbidden"), (is_forbidden_bidi_char, "forbidden bidi character"), ): if f(c): raise ScramException(msg, SERVER_ERROR_INVALID_ENCODING) return data scramp-1.4.5/src/scramp/utils.py0000644000000000000000000000121713615410400013515 0ustar00import hmac as hmaca from base64 import b64decode, b64encode def hmac(hf, key, msg): return hmaca.new(key, msg=msg, digestmod=hf).digest() def h(hf, msg): return hf(msg).digest() def hi(hf, password, salt, iterations): u = ui = hmac(hf, password, salt + b"\x00\x00\x00\x01") for i in range(iterations - 1): ui = hmac(hf, password, ui) u = xor(u, ui) return u def xor(bytes1, bytes2): return bytes(a ^ b for a, b in zip(bytes1, bytes2)) def b64enc(binary): return b64encode(binary).decode("utf8") def b64dec(string): return b64decode(string) def uenc(string): return string.encode("utf-8") scramp-1.4.5/test/test_readme.py0000644000000000000000000000033513615410400013554 0ustar00import doctest from pathlib import Path def test_readme(): failure_count, _ = doctest.testfile( str(Path("..") / "README.md"), verbose=False, optionflags=doctest.ELLIPSIS ) assert failure_count == 0 scramp-1.4.5/test/test_scramp.py0000644000000000000000000004330413615410400013607 0ustar00import hashlib import pytest from scramp import ( ScramClient, ScramException, ScramMechanism, core, make_channel_binding, ) from scramp.core import _parse_message from scramp.utils import b64dec @pytest.mark.parametrize( "msg,validations,error_msg", [ [ "", ["abc"], "Malformed trial message. Attributes must be separated by a ',' and each " "attribute must start with a letter followed by a '=': other-error", ], [ "c=jk,d=kln", ["abc"], "Malformed trial message. Expected the attribute list to be 'abc' but " "found 'cd': other-error", ], ], ) def test_parse_message_fail(msg, validations, error_msg): with pytest.raises(ScramException, match=error_msg): _parse_message(msg, "trial", *validations) @pytest.mark.parametrize( "msg,validations,result", [ ["c=jk,d=kln", ["cd"], {"c": "jk", "d": "kln"}], ["c=jk,d=kln", ["abc", "cd"], {"c": "jk", "d": "kln"}], ["c=", ["c"], {"c": ""}], ], ) def test_parse_message_succeed(msg, validations, result): assert _parse_message(msg, "trial", *validations) == result EXCHANGE_SCRAM_SHA_256 = { "username": "user", "password": "pencil", "c_mechanisms": ["SCRAM-SHA-256"], "s_mechanism": "SCRAM-SHA-256", "cfirst": "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", "sfirst": "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", "cfinal": "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", "cfinal_without_proof": "c=biws," "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "sfinal": "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=", "cfirst_bare": "n=user,r=rOprNGfwEbeRWgbNEkqO", "c_nonce": "rOprNGfwEbeRWgbNEkqO", "s_nonce": "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "nonce": "rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "auth_message": b"n=user,r=rOprNGfwEbeRWgbNEkqO," b"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," b"s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,c=biws," b"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "salt": "W22ZaJ0SNY7soEsUEjb6gQ==", "iterations": 4096, "server_signature": "6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=", "hf": hashlib.sha256, "stored_key": "WG5d8oPm3OtcPnkdi4Uo7BkeZkBFzpcXkuLmtbsT4qY=", "server_key": "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=", "c_use_binding": False, "s_init_use_binding": False, "s_use_binding": False, "c_channel_binding": None, "s_channel_binding": None, } EXCHANGE_SCRAM_SHA_256_PLUS = { "username": "user", "password": "pencil", "c_mechanisms": ["SCRAM-SHA-256-PLUS"], "s_mechanism": "SCRAM-SHA-256-PLUS", "cfirst": "p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO", "sfirst": "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", "cfinal": "c=cD10bHMtdW5pcXVlLCx4eHg=," "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "p=v0J7PaQUPWowoTrwRLCKLzIZBpNUhWFlTrUKI1j9DpM=", "cfinal_without_proof": "c=cD10bHMtdW5pcXVlLCx4eHg=," "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "sfinal": "v=XjAev9iHBOvTxT+eNzBaFmP1IrqWah2PpZAa0wQrfY4=", "cfirst_bare": "n=user,r=rOprNGfwEbeRWgbNEkqO", "c_nonce": "rOprNGfwEbeRWgbNEkqO", "s_nonce": "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "nonce": "rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "auth_message": b"n=user,r=rOprNGfwEbeRWgbNEkqO," b"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," b"s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,c=cD10bHMtdW5pcXVlLCx4eHg=," b"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", "salt": "W22ZaJ0SNY7soEsUEjb6gQ==", "iterations": 4096, "server_signature": "XjAev9iHBOvTxT+eNzBaFmP1IrqWah2PpZAa0wQrfY4=", "hf": hashlib.sha256, "stored_key": "WG5d8oPm3OtcPnkdi4Uo7BkeZkBFzpcXkuLmtbsT4qY=", "server_key": "wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU=", "c_use_binding": True, "s_init_use_binding": True, "s_use_binding": True, "c_channel_binding": ("tls-unique", b"xxx"), "s_channel_binding": ("tls-unique", b"xxx"), } params = [ # Standard SCRAM_SHA_1 { "username": "user", "password": "pencil", "c_mechanisms": ["SCRAM-SHA-1"], "s_mechanism": "SCRAM-SHA-1", "cfirst": "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "cfirst_bare": "n=user,r=fyko+d2lbbFgONRv9qkxdawL", "sfirst": "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "s=QSXCR+Q6sek8bf92,i=4096", "cfinal": "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=", "cfinal_without_proof": "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "sfinal": "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=", "c_nonce": "fyko+d2lbbFgONRv9qkxdawL", "s_nonce": "3rfcNHYJY1ZVvWVs7j", "nonce": "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "auth_message": b"n=user,r=fyko+d2lbbFgONRv9qkxdawL," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," b"s=QSXCR+Q6sek8bf92,i=4096,c=biws," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "salt": "QSXCR+Q6sek8bf92", "iterations": 4096, "server_signature": "rmF9pqV8S7suAoZWja4dJRkFsKQ=", "hf": hashlib.sha1, "stored_key": "6dlGYMOdZcOPutkcNY8U2g7vK9Y=", "server_key": "D+CSWLOshSulAsxiupA+qs2/fTE=", "c_use_binding": False, "s_init_use_binding": False, "s_use_binding": False, "c_channel_binding": None, "s_channel_binding": None, }, # SCRAM_SHA_1 where the client supports channel binding but the server does not { "username": "user", "password": "pencil", "c_mechanisms": ["SCRAM-SHA-1"], "s_mechanism": "SCRAM-SHA-1", "cfirst": "y,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "sfirst": "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "s=QSXCR+Q6sek8bf92,i=4096", "cfinal": "c=eSws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "p=BjZF5dV+EkD3YCb3pH3IP8riMGw=", "cfinal_without_proof": "c=eSws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "sfinal": "v=dsprQ5R2AGYt1kn4bQRwTAE0PTU=", "cfirst_bare": "n=user,r=fyko+d2lbbFgONRv9qkxdawL", "c_nonce": "fyko+d2lbbFgONRv9qkxdawL", "s_nonce": "3rfcNHYJY1ZVvWVs7j", "nonce": "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "auth_message": b"n=user,r=fyko+d2lbbFgONRv9qkxdawL," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," b"s=QSXCR+Q6sek8bf92,i=4096,c=eSws," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "salt": "QSXCR+Q6sek8bf92", "iterations": 4096, "server_signature": "dsprQ5R2AGYt1kn4bQRwTAE0PTU=", "hf": hashlib.sha1, "stored_key": "6dlGYMOdZcOPutkcNY8U2g7vK9Y=", "server_key": "D+CSWLOshSulAsxiupA+qs2/fTE=", "c_use_binding": False, "s_init_use_binding": False, "s_use_binding": False, "c_channel_binding": ("tls-unique", b"xxx"), "s_channel_binding": None, }, # Standard SCRAM_SHA_1_PLUS { "username": "user", "password": "pencil", "c_mechanisms": ["SCRAM-SHA-1-PLUS"], "s_mechanism": "SCRAM-SHA-1-PLUS", "cfirst": "p=tls-unique,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "sfirst": "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "s=QSXCR+Q6sek8bf92,i=4096", "cfinal": "c=cD10bHMtdW5pcXVlLCx4eHg=," "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "p=/63TtbB5lIS6610+k4/luJMJqAI=", "cfinal_without_proof": "c=cD10bHMtdW5pcXVlLCx4eHg=," "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "sfinal": "v=GCPHy5gy1sRwXTCbwNhiiWIzLtU=", "cfirst_bare": "n=user,r=fyko+d2lbbFgONRv9qkxdawL", "c_nonce": "fyko+d2lbbFgONRv9qkxdawL", "s_nonce": "3rfcNHYJY1ZVvWVs7j", "nonce": "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "auth_message": b"n=user,r=fyko+d2lbbFgONRv9qkxdawL," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," b"s=QSXCR+Q6sek8bf92,i=4096,c=cD10bHMtdW5pcXVlLCx4eHg=," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "salt": "QSXCR+Q6sek8bf92", "iterations": 4096, "server_signature": "GCPHy5gy1sRwXTCbwNhiiWIzLtU=", "hf": hashlib.sha1, "stored_key": "6dlGYMOdZcOPutkcNY8U2g7vK9Y=", "server_key": "D+CSWLOshSulAsxiupA+qs2/fTE=", "c_use_binding": True, "s_init_use_binding": True, "s_use_binding": True, "c_channel_binding": ("tls-unique", b"xxx"), "s_channel_binding": ("tls-unique", b"xxx"), }, EXCHANGE_SCRAM_SHA_256, EXCHANGE_SCRAM_SHA_256_PLUS, ] @pytest.mark.parametrize("x", params) def test_get_client_first(x): cfirst_bare, cfirst = core._get_client_first( x["username"], x["c_nonce"], x["c_channel_binding"], x["c_use_binding"] ) assert cfirst_bare == x["cfirst_bare"] assert cfirst == x["cfirst"] @pytest.mark.parametrize("x", params) def test_make_auth_message(x): auth_msg = core._make_auth_message( x["cfirst_bare"], x["sfirst"], x["cfinal_without_proof"], ) assert auth_msg == x["auth_message"] @pytest.mark.parametrize("x", params) def test_get_client_final(x): server_signature, cfinal = core._get_client_final( x["hf"], x["password"], x["salt"], x["iterations"], x["nonce"], x["cfirst_bare"], x["sfirst"], x["c_channel_binding"], x["c_use_binding"], ) assert server_signature == x["server_signature"] assert cfinal == x["cfinal"] @pytest.mark.parametrize("x", params) def test_client_order(x): c = ScramClient( x["c_mechanisms"], x["username"], x["password"], channel_binding=x["c_channel_binding"], ) with pytest.raises(ScramException): c.set_server_first(x["sfirst"]) @pytest.mark.parametrize("x", params) def test_client(x): c = ScramClient( x["c_mechanisms"], x["username"], x["password"], channel_binding=x["c_channel_binding"], c_nonce=x["c_nonce"], ) assert c.get_client_first() == x["cfirst"] c.set_server_first(x["sfirst"]) assert c.get_client_final() == x["cfinal"] @pytest.mark.parametrize("x", params) def test_set_client_first(x): nonce, user, cfirst_bare, upgrade_mechanism = core._set_client_first( x["cfirst"], x["s_nonce"], x["s_channel_binding"], x["s_init_use_binding"] ) assert nonce == x["nonce"] assert user == x["username"] assert cfirst_bare == x["cfirst_bare"] assert upgrade_mechanism == (x["s_init_use_binding"] != x["s_use_binding"]) @pytest.mark.parametrize("x", params) def test_get_server_first(x): sfirst = core._get_server_first(x["nonce"], x["salt"], x["iterations"]) assert sfirst == x["sfirst"] @pytest.mark.parametrize("x", params) def test_set_client_final(x): server_signature = core._set_client_final( x["hf"], x["cfinal"], x["s_nonce"], b64dec(x["stored_key"]), b64dec(x["server_key"]), x["cfirst_bare"], x["sfirst"], x["s_channel_binding"], x["s_use_binding"], ) assert server_signature == x["server_signature"] @pytest.mark.parametrize("x", params) def test_get_server_final(x): server_final = core._get_server_final(x["server_signature"], None) assert server_final == x["sfinal"] @pytest.mark.parametrize("x", params) def test_server_order(x): m = ScramMechanism(mechanism=x["s_mechanism"]) def auth_fn(username): lookup = { x["username"]: m.make_auth_info( x["password"], salt=x["salt"], iteration_count=x["iterations"] ) } return lookup[username] s = m.make_server(auth_fn, channel_binding=x["s_channel_binding"]) with pytest.raises(ScramException): s.set_client_final(x["cfinal"]) @pytest.mark.parametrize("x", params) def test_server(x): m = ScramMechanism(mechanism=x["s_mechanism"]) def auth_fn(username): lookup = { x["username"]: m.make_auth_info( x["password"], salt=b64dec(x["salt"]), iteration_count=x["iterations"] ) } return lookup[username] s = m.make_server( auth_fn, channel_binding=x["s_channel_binding"], s_nonce=x["s_nonce"] ) s.set_client_first(x["cfirst"]) assert s.get_server_first() == x["sfirst"] s.set_client_final(x["cfinal"]) assert s.get_server_final() == x["sfinal"] def test_check_stage(): with pytest.raises( ScramException, match="The next method to be called is get_server_first, not this " "method.", ): core._check_stage( core.ServerStage, core.ServerStage.set_client_first, core.ServerStage.get_server_final, ) def test_set_client_first_error(): x = EXCHANGE_SCRAM_SHA_256 m = ScramMechanism(mechanism="SCRAM-SHA-256") def auth_fn(username): lookup = { x["username"]: m.make_auth_info( x["password"], salt=b64dec(x["salt"]), iteration_count=x["iterations"] ) } return lookup[username] s = m.make_server( auth_fn, channel_binding=x["s_channel_binding"], s_nonce=x["s_nonce"] ) with pytest.raises( ScramException, match="Received GS2 flag 'p' which indicates that the client " "requires channel binding, but the server does not.", ): s.set_client_first("p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO") assert s.get_server_final() == "e=channel-binding-not-supported" def test_set_client_final_error(): x = EXCHANGE_SCRAM_SHA_256 m = ScramMechanism(mechanism="SCRAM-SHA-256") def auth_fn(username): lookup = { x["username"]: m.make_auth_info( x["password"], salt=b64dec(x["salt"]), iteration_count=x["iterations"] ) } return lookup[username] s = m.make_server( auth_fn, channel_binding=x["s_channel_binding"], s_nonce=x["s_nonce"] ) s.set_client_first(x["cfirst"]) s.get_server_first() with pytest.raises(ScramException, match="other-error"): s.set_client_final( "c=biws,r=rOprNGfwEbeRWgbNEkqO_invalid," "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=" ) assert s.get_server_final() == "e=other-error" def test_set_server_first_error(): c = ScramClient(["SCRAM-SHA-256"], "user", "pencil") c.get_client_first() with pytest.raises(ScramException, match="other-error"): c.set_server_first("e=other-error") def test_set_server_first_missing_param(): c = ScramClient(["SCRAM-SHA-256"], "user", "pencil") c.get_client_first() with pytest.raises( ScramException, match="Malformed server first message. Attributes must be separated by a ',' " "and each attribute must start with a letter followed by a '=': other-error", ): c.set_server_first("junk") def test_set_server_final_missing_param(): x = EXCHANGE_SCRAM_SHA_256 c = ScramClient( x["c_mechanisms"], x["username"], x["password"], c_nonce=x["c_nonce"], ) c.get_client_first() c.set_server_first(x["sfirst"]) c.get_client_final() with pytest.raises( ScramException, match="Malformed server final message. Attributes must be separated by a ',' " "and each attribute must start with a letter followed by a '=': other-error", ): c.set_server_final("junk") def test_set_client_first_nonsense(): m = ScramMechanism(mechanism="SCRAM-SHA-256") s = m.make_server(lambda x: None) with pytest.raises( ScramException, match="The client sent a malformed first message." ): s.set_client_first("junk") def test_set_client_first_missing_param(): m = ScramMechanism(mechanism="SCRAM-SHA-256") s = m.make_server(lambda x: None) with pytest.raises( ScramException, match="Malformed client first bare message. Attributes must be separated by a " "',' and each attribute must start with a letter followed by a '=': " "other-error", ): s.set_client_first("n,morejunk,bonusjunk") def test_set_client_final_missing_param(): x = EXCHANGE_SCRAM_SHA_256 m = ScramMechanism(mechanism="SCRAM-SHA-256") def auth_fn(username): lookup = { x["username"]: m.make_auth_info( x["password"], salt=b64dec(x["salt"]), iteration_count=x["iterations"] ) } return lookup[username] s = m.make_server( auth_fn, channel_binding=x["s_channel_binding"], s_nonce=x["s_nonce"] ) s.set_client_first(x["cfirst"]) s.get_server_first() with pytest.raises( ScramException, match="Malformed client final message. Attributes must be separated by a ',' " "and each attribute must start with a letter followed by a '=': other-error", ): s.set_client_final("junk") def test_make_channel_binding_tls_server_end_point(mocker): ssl_socket = mocker.Mock() ssl_socket.getpeercert = mocker.Mock(return_value=b"cafe") mock_cert = mocker.Mock() mock_cert.hash_algo = "sha512" mocker.patch("scramp.core.Certificate.load", return_value=mock_cert) result = make_channel_binding("tls-server-end-point", ssl_socket) assert result == ( "tls-server-end-point", b"5\x9dQ\xe2\xc4a\x17g\x1bK\xeci\x98\x9e\x16R\x96}\xe4~D\x15\xfb\xb3\x1fn]=" b"\re?s\x10\xf2\xf8\xa6+\x91i\x9d\x84,iO\x8emDu\xb4\x19\x06i\xa7\x1a\xf1i\xc6" b"K\x81\xcbp\xd1\xaf\xd7", ) scramp-1.4.5/.gitignore0000644000000000000000000000230613615410400011717 0ustar00# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ *.swp README.html scramp-1.4.5/LICENSE0000644000000000000000000000161213615410400010733 0ustar00MIT No Attribution Copyright The Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. scramp-1.4.5/README.md0000644000000000000000000004302413615410400011210 0ustar00# Scramp A Python implementation of the [SCRAM authentication protocol]( https://en.wikipedia.org/wiki/Salted_Challenge_Response_Authentication_Mechanism>). Scramp supports the following mechanisms: - SCRAM-SHA-1 - SCRAM-SHA-1-PLUS - SCRAM-SHA-256 - SCRAM-SHA-256-PLUS - SCRAM-SHA-512 - SCRAM-SHA-512-PLUS - SCRAM-SHA3-512 - SCRAM-SHA3-512-PLUS ## Installation - Create a virtual environment: `python3 -m venv venv` - Activate the virtual environment: `source venv/bin/activate` - Install: `pip install scramp` ## Examples ### Client and Server Here's an example using both the client and the server. It's a bit contrived as normally you'd be using either the client or server on its own. ```python >>> from scramp import ScramClient, ScramMechanism >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> MECHANISMS = ['SCRAM-SHA-256'] >>> >>> >>> # Choose a mechanism for our server >>> m = ScramMechanism() # Default is SCRAM-SHA-256 >>> >>> # On the server side we create the authentication information for each user >>> # and store it in an authentication database. We'll use a dict: >>> db = {} >>> >>> salt, stored_key, server_key, iteration_count = m.make_auth_info(PASSWORD) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for retrieving the authentication information >>> # from the database given a username >>> >>> def auth_fn(username): ... return db[username] >>> >>> # Make the SCRAM server >>> s = m.make_server(auth_fn) >>> >>> # Now set up the client and carry out authentication with the server >>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD) >>> cfirst = c.get_client_first() >>> >>> s.set_client_first(cfirst) >>> sfirst = s.get_server_first() >>> >>> c.set_server_first(sfirst) >>> cfinal = c.get_client_final() >>> >>> s.set_client_final(cfinal) >>> sfinal = s.get_server_final() >>> >>> c.set_server_final(sfinal) >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Client only Here's an example using just the client. The client nonce is specified in order to give a reproducible example, but in production you'd omit the `c_nonce` parameter and let `ScramClient` generate a client nonce: ```python >>> from scramp import ScramClient >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> C_NONCE = 'rOprNGfwEbeRWgbNEkqO' >>> MECHANISMS = ['SCRAM-SHA-256'] >>> >>> # Normally the c_nonce would be omitted, in which case ScramClient will >>> # generate the nonce itself. >>> >>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD, c_nonce=C_NONCE) >>> >>> # Get the client first message and send it to the server >>> cfirst = c.get_client_first() >>> print(cfirst) n,,n=user,r=rOprNGfwEbeRWgbNEkqO >>> >>> # Set the first message from the server >>> c.set_server_first( ... 'r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' ... 's=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096') >>> >>> # Get the client final message and send it to the server >>> cfinal = c.get_client_final() >>> print(cfinal) c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ= >>> >>> # Set the final message from the server >>> c.set_server_final('v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=') >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Server only Here's an example using just the server. The server nonce and salt is specified in order to give a reproducible example, but in production you'd omit the `s_nonce` and `salt` parameters and let Scramp generate them: ```python >>> from scramp import ScramMechanism >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' >>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' >>> >>> db = {} >>> >>> m = ScramMechanism() >>> >>> salt, stored_key, server_key, iteration_count = m.make_auth_info( ... PASSWORD, salt=SALT) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for getting a password given a username >>> def auth_fn(username): ... return db[username] >>> >>> # Normally the s_nonce parameter would be omitted, in which case the >>> # server will generate the nonce itself. >>> >>> s = m.make_server(auth_fn, s_nonce=S_NONCE) >>> >>> # Set the first message from the client >>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') >>> >>> # Get the first server message, and send it to the client >>> sfirst = s.get_server_first() >>> print(sfirst) r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 >>> >>> # Set the final message from the client >>> s.set_client_final( ... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' ... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') >>> >>> # Get the final server message and send it to the client >>> sfinal = s.get_server_final() >>> print(sfinal) v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Server only with passlib Here's an example using just the server and using the [passlib hashing library]( https://passlib.readthedocs.io/en/stable/index.html). The server nonce and salt is specified in order to give a reproducible example, but in production you'd omit the `s_nonce` and `salt` parameters and let Scramp generate them: ```python >>> from scramp import ScramMechanism >>> from passlib.hash import scram >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' >>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' >>> ITERATION_COUNT = 4096 >>> >>> db = {} >>> hash = scram.using(salt=SALT, rounds=ITERATION_COUNT).hash(PASSWORD) >>> >>> salt, iteration_count, digest = scram.extract_digest_info(hash, 'sha-256') >>> >>> stored_key, server_key = m.make_stored_server_keys(digest) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for getting a password given a username >>> def auth_fn(username): ... return db[username] >>> >>> # Normally the s_nonce parameter would be omitted, in which case the >>> # server will generate the nonce itself. >>> >>> m = ScramMechanism() >>> s = m.make_server(auth_fn, s_nonce=S_NONCE) >>> >>> # Set the first message from the client >>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') >>> >>> # Get the first server message, and send it to the client >>> sfirst = s.get_server_first() >>> print(sfirst) r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 >>> >>> # Set the final message from the client >>> s.set_client_final( ... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' ... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') >>> >>> # Get the final server message and send it to the client >>> sfinal = s.get_server_final() >>> print(sfinal) v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Server Error Here's an example of when setting a message from the client causes an error. The server nonce and salt is specified in order to give a reproducible example, but in production you'd omit the `s_nonce` and `salt` parameters and let Scramp generate them: ```python >>> from scramp import ScramException, ScramMechanism >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' >>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' >>> >>> db = {} >>> >>> m = ScramMechanism() >>> >>> salt, stored_key, server_key, iteration_count = m.make_auth_info( ... PASSWORD, salt=SALT) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for getting a password given a username >>> def auth_fn(username): ... return db[username] >>> >>> # Normally the s_nonce parameter would be omitted, in which case the >>> # server will generate the nonce itself. >>> >>> s = m.make_server(auth_fn, s_nonce=S_NONCE) >>> >>> try: ... # Set the first message from the client ... s.set_client_first('p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO') ... except ScramException as e: ... print(e) ... # Get the final server message and send it to the client ... sfinal = s.get_server_final() ... print(sfinal) Received GS2 flag 'p' which indicates that the client requires channel binding, but the server does not: channel-binding-not-supported e=channel-binding-not-supported ``` ### Standards - [RFC 5802](https://tools.ietf.org/html/rfc5802>) Describes SCRAM. - [RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677>) Registers SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. - [draft-melnikov-scram-sha-512-02](https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha-512) Registers SCRAM-SHA-512 and SCRAM-SHA-512-PLUS. - [draft-melnikov-scram-sha3-512](https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha3-512) Registers SCRAM-SHA3-512 and SCRAM-SHA3-512-PLUS. - [RFC 5929](https://datatracker.ietf.org/doc/html/rfc5929) Channel Bindings for TLS. - [draft-ietf-kitten-tls-channel-bindings-for-tls13](https://datatracker.ietf.org/doc/html/draft-ietf-kitten-tls-channel-bindings-for-tls13>) Defines the `tls-exporter` channel binding, which is [not yet supported by Scramp](https://github.com/tlocke/scramp/issues/9). ## API Docs ### scramp.MECHANISMS A tuple of the supported mechanism names. ### scramp.ScramClient `ScramClient(mechanisms, username, password, channel_binding=None, c_nonce=None)` Constructor of the `scramp.ScramClient` class, with the following parameters: - `mechanisms` - A list or tuple of mechanism names. ScramClient will choose the most secure. If `cbind_data` is `None`, the '-PLUS' variants will be filtered out first. The chosen mechanism is available as the property `mechanism_name`. - `username` - `password` - `channel_binding` - Providing a value for this parameter allows channel binding to be used (ie. it lets you use mechanisms ending in '-PLUS'). The value for `channel_binding` is a tuple consisting of the channel binding name and the channel binding data. For example, if the channel binding name is `tls-unique`, the `channel_binding` parameter would be `('tls-unique', data)`, where `data` is obtained by calling [SSLSocket.get\_channel\_binding()](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding). The convenience function `scramp.make_channel_binding()` can be used to create a channel binding tuple. - `c_nonce` - The client nonce. It's sometimes useful to set this when testing / debugging, but in production this should be omitted, in which case `ScramClient` will generate a client nonce. The `ScramClient` object has the following methods and properties: - `get_client_first()` - Get the client first message. - `set_server_first(message)` - Set the first message from the server. - `get_client_final()` - Get the final client message. - `set_server_final(message)` - Set the final message from the server. - `mechanism_name` - The mechanism chosen from the list given in the constructor. ### scramp.ScramMechanism `ScramMechanism(mechanism='SCRAM-SHA-256')` Constructor of the `ScramMechanism` class, with the following parameter: - `mechanism` - The SCRAM mechanism to use. The `ScramMechanism` object has the following methods and properties: - `make_auth_info(password, iteration_count=None, salt=None)` - Returns the tuple `(salt, stored_key, server_key, iteration_count)` which is stored in the authentication database on the server side. It has the following parameters: - `password` - The user's password as a `str`. - `iteration_count` - The rounds as an `int`. If `None` then use the minimum associated with the mechanism. - `salt` - It's sometimes useful to set this binary parameter when testing / debugging, but in production this should be omitted, in which case a salt will be generated. - `make_server(auth_fn, channel_binding=None, s_nonce=None)` - returns a `ScramServer` object. It takes the following parameters: - `auth_fn` This is a function provided by the programmer that has one parameter, a username of type `str` and returns returns the tuple `(salt, stored_key, server_key, iteration_count)`. Where `salt`, `stored_key` and `server_key` are of a binary type, and `iteration_count` is an `int`. - `channel_binding` - Providing a value for this parameter allows channel binding to be used (ie. it lets you use mechanisms ending in `-PLUS`). The value for `channel_binding` is a tuple consisting of the channel binding name and the channel binding data. For example, if the channel binding name is 'tls-unique', the `channel_binding` parameter would be `('tls-unique', data)`, where `data` is obtained by calling [SSLSocket.get\_channel\_binding()](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding>). The convenience function `scramp.make_channel_binding()` can be used to create a channel binding tuple. If `channel_binding` is provided and the mechanism isn't a `-PLUS` variant, then the server will negotiate with the client to use the `-PLUS` variant if the client supports it, or otherwise to use the mechanism without channel binding. - `s_nonce` - The server nonce as a `str`. It's sometimes useful to set this when testing / debugging, but in production this should be omitted, in which case `ScramServer` will generate a server nonce. - `make_stored_server_keys(salted_password)` - Returns `(stored_key, server_key)` tuple of `bytes` objects given a salted password. This is useful if you want to use a separate hashing implementation from the one provided by Scramp. It takes the following parameter: - `salted_password` - A binary object representing the hashed password. - `iteration_count` - The minimum iteration count recommended for this mechanism. ### scramp.ScramServer The `ScramServer` object has the following methods: - `set_client_first(message)` - Set the first message from the client. - `get_server_first()` - Get the server first message. - `set_client_final(message)` - Set the final client message. - `get_server_final()` - Get the server final message. ### scramp.make\_channel\_binding(name, ssl\_socket) A helper function that makes a `channel_binding` tuple when given a channel binding name and an SSL socket. The parameters are: - `name` - A channel binding name such as 'tls-unique' or 'tls-server-end-point'. - `ssl_socket` - An instance of [ssl.SSLSocket](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket). ## Testing - Activate the virtual environment: `source venv/bin/activate` - Install `tox`: `pip install tox` - Run `tox`: `tox` ## OpenSSF Scorecard It might be worth running the [OpenSSF Scorecard](https://securityscorecards.dev/): `sudo docker run -e GITHUB_AUTH_TOKEN= gcr.io/openssf/scorecard:stable --repo=github.com/tlocke/scramp` ## Doing A Release Of Scramp Run `tox` to make sure all tests pass, then update the release notes, then do: - `git tag -a x.y.z -m "version x.y.z"` - `rm -r dist` - `python -m build` - `twine upload dist/*` ## Release Notes ### Version 1.4.5, 2024-04-13 - Drop support for Python 3.7, which means we're not dependent on `importlib-metadata` anymore. - Various changes to build system, docs and automated testing. ### Version 1.4.4, 2022-11-01 - Tighten up parsing of messages to make sure that a `ScramException` is raised if a message is malformed. ### Version 1.4.3, 2022-10-26 - The client now sends a gs2-cbind-flag of 'y' if the client supports channel binding, but thinks the server does not. ### Version 1.4.2, 2022-10-22 - Switch to using the MIT-0 licence https://choosealicense.com/licenses/mit-0/ - When creating a ScramClient, allow non ``-PLUS`` variants, even if a `channel_binding` parameter is provided. Previously this would raise and exception. ### Version 1.4.1, 2021-08-25 - When using `make_channel_binding()` to create a tls-server-end-point channel binding, support certificates with hash algorithm of sha512. ### Version 1.4.0, 2021-03-28 - Raise an exception if the client receives an error from the server. ### Version 1.3.0, 2021-03-28 - As the specification allows, server errors are now sent to the client in the `server_final` message, an exception is still thrown as before. ### Version 1.2.2, 2021-02-13 - Fix bug in generating the AuthMessage. It was incorrect when channel binding was used. So now Scramp supports channel binding. ### Version 1.2.1, 2021-02-07 - Add support for channel binding. - Add support for SCRAM-SHA-512 and SCRAM-SHA3-512 and their channel binding variants. ### Version 1.2.0, 2020-05-30 - This is a backwardly incompatible change on the server side, the client side will work as before. The idea of this change is to make it possible to have an authentication database. That is, the authentication information can be stored, and then retrieved when needed to authenticate the user. - In addition, it's now possible on the server side to use a third party hashing library such as passlib as the hashing implementation. ### Version 1.1.1, 2020-03-28 - Add the README and LICENCE to the distribution. ### Version 1.1.0, 2019-02-24 - Add support for the SCRAM-SHA-1 mechanism. ### Version 1.0.0, 2019-02-17 - Implement the server side as well as the client side. ### Version 0.0.0, 2019-02-10 - Copied SCRAM implementation from [pg8000](https://github.com/tlocke/pg8000). The idea is to make it a general SCRAM implemtation. Credit to the [Scrampy](https://github.com/cagdass/scrampy) project which I read through to help with this project. Also credit to the [passlib](https://github.com/efficks/passlib) project from which I copied the `saslprep` function. scramp-1.4.5/pyproject.toml0000644000000000000000000000350413615410400012644 0ustar00[build-system] requires = ["hatchling", "versioningit"] build-backend = "hatchling.build" [project] name = "scramp" authors = [{name = "The Contributors"}] description = "An implementation of the SCRAM protocol." readme = "README.md" requires-python = ">=3.8" keywords = ["SCRAM", "authentication", "SASL"] license = {text = "MIT No Attribution"} classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT No Attribution License (MIT-0)", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Operating System :: OS Independent", ] dependencies = [ "asn1crypto >= 1.5.1", ] dynamic = ["version"] [project.urls] Homepage = "https://github.com/tlocke/scramp" [tool.hatch.version] source = "versioningit" [tool.versioningit] [tool.versioningit.vcs] method = "git" default-tag = "0.0.0" [tool.flake8] application-names = ["scramp"] ignore = ["E203", "W503"] max-line-length = 88 exclude = [".git", "__pycache__", "build", "dist", "venv", ".tox"] application-import-names = ["scramp"] [tool.tox] legacy_tox_ini = """ [tox] isolated_build = True envlist = py [testenv] allowlist_externals=/usr/bin/rm commands = black --check . flake8 . pytest -v -x -W error test rm -rf dist python -m build twine check dist/* deps = build pytest pytest-mock black flake8 Flake8-pyproject flake8-alphabetize passlib twine """ scramp-1.4.5/PKG-INFO0000644000000000000000000004511413615410400011030 0ustar00Metadata-Version: 2.3 Name: scramp Version: 1.4.5 Summary: An implementation of the SCRAM protocol. Project-URL: Homepage, https://github.com/tlocke/scramp Author: The Contributors License: MIT No Attribution License-File: LICENSE Keywords: SASL,SCRAM,authentication Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0) Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: Implementation Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=3.8 Requires-Dist: asn1crypto>=1.5.1 Description-Content-Type: text/markdown # Scramp A Python implementation of the [SCRAM authentication protocol]( https://en.wikipedia.org/wiki/Salted_Challenge_Response_Authentication_Mechanism>). Scramp supports the following mechanisms: - SCRAM-SHA-1 - SCRAM-SHA-1-PLUS - SCRAM-SHA-256 - SCRAM-SHA-256-PLUS - SCRAM-SHA-512 - SCRAM-SHA-512-PLUS - SCRAM-SHA3-512 - SCRAM-SHA3-512-PLUS ## Installation - Create a virtual environment: `python3 -m venv venv` - Activate the virtual environment: `source venv/bin/activate` - Install: `pip install scramp` ## Examples ### Client and Server Here's an example using both the client and the server. It's a bit contrived as normally you'd be using either the client or server on its own. ```python >>> from scramp import ScramClient, ScramMechanism >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> MECHANISMS = ['SCRAM-SHA-256'] >>> >>> >>> # Choose a mechanism for our server >>> m = ScramMechanism() # Default is SCRAM-SHA-256 >>> >>> # On the server side we create the authentication information for each user >>> # and store it in an authentication database. We'll use a dict: >>> db = {} >>> >>> salt, stored_key, server_key, iteration_count = m.make_auth_info(PASSWORD) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for retrieving the authentication information >>> # from the database given a username >>> >>> def auth_fn(username): ... return db[username] >>> >>> # Make the SCRAM server >>> s = m.make_server(auth_fn) >>> >>> # Now set up the client and carry out authentication with the server >>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD) >>> cfirst = c.get_client_first() >>> >>> s.set_client_first(cfirst) >>> sfirst = s.get_server_first() >>> >>> c.set_server_first(sfirst) >>> cfinal = c.get_client_final() >>> >>> s.set_client_final(cfinal) >>> sfinal = s.get_server_final() >>> >>> c.set_server_final(sfinal) >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Client only Here's an example using just the client. The client nonce is specified in order to give a reproducible example, but in production you'd omit the `c_nonce` parameter and let `ScramClient` generate a client nonce: ```python >>> from scramp import ScramClient >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> C_NONCE = 'rOprNGfwEbeRWgbNEkqO' >>> MECHANISMS = ['SCRAM-SHA-256'] >>> >>> # Normally the c_nonce would be omitted, in which case ScramClient will >>> # generate the nonce itself. >>> >>> c = ScramClient(MECHANISMS, USERNAME, PASSWORD, c_nonce=C_NONCE) >>> >>> # Get the client first message and send it to the server >>> cfirst = c.get_client_first() >>> print(cfirst) n,,n=user,r=rOprNGfwEbeRWgbNEkqO >>> >>> # Set the first message from the server >>> c.set_server_first( ... 'r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' ... 's=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096') >>> >>> # Get the client final message and send it to the server >>> cfinal = c.get_client_final() >>> print(cfinal) c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ= >>> >>> # Set the final message from the server >>> c.set_server_final('v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=') >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Server only Here's an example using just the server. The server nonce and salt is specified in order to give a reproducible example, but in production you'd omit the `s_nonce` and `salt` parameters and let Scramp generate them: ```python >>> from scramp import ScramMechanism >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' >>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' >>> >>> db = {} >>> >>> m = ScramMechanism() >>> >>> salt, stored_key, server_key, iteration_count = m.make_auth_info( ... PASSWORD, salt=SALT) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for getting a password given a username >>> def auth_fn(username): ... return db[username] >>> >>> # Normally the s_nonce parameter would be omitted, in which case the >>> # server will generate the nonce itself. >>> >>> s = m.make_server(auth_fn, s_nonce=S_NONCE) >>> >>> # Set the first message from the client >>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') >>> >>> # Get the first server message, and send it to the client >>> sfirst = s.get_server_first() >>> print(sfirst) r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 >>> >>> # Set the final message from the client >>> s.set_client_final( ... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' ... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') >>> >>> # Get the final server message and send it to the client >>> sfinal = s.get_server_final() >>> print(sfinal) v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Server only with passlib Here's an example using just the server and using the [passlib hashing library]( https://passlib.readthedocs.io/en/stable/index.html). The server nonce and salt is specified in order to give a reproducible example, but in production you'd omit the `s_nonce` and `salt` parameters and let Scramp generate them: ```python >>> from scramp import ScramMechanism >>> from passlib.hash import scram >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' >>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' >>> ITERATION_COUNT = 4096 >>> >>> db = {} >>> hash = scram.using(salt=SALT, rounds=ITERATION_COUNT).hash(PASSWORD) >>> >>> salt, iteration_count, digest = scram.extract_digest_info(hash, 'sha-256') >>> >>> stored_key, server_key = m.make_stored_server_keys(digest) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for getting a password given a username >>> def auth_fn(username): ... return db[username] >>> >>> # Normally the s_nonce parameter would be omitted, in which case the >>> # server will generate the nonce itself. >>> >>> m = ScramMechanism() >>> s = m.make_server(auth_fn, s_nonce=S_NONCE) >>> >>> # Set the first message from the client >>> s.set_client_first('n,,n=user,r=rOprNGfwEbeRWgbNEkqO') >>> >>> # Get the first server message, and send it to the client >>> sfirst = s.get_server_first() >>> print(sfirst) r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096 >>> >>> # Set the final message from the client >>> s.set_client_final( ... 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,' ... 'p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=') >>> >>> # Get the final server message and send it to the client >>> sfinal = s.get_server_final() >>> print(sfinal) v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4= >>> >>> # If it all runs through without raising an exception, the authentication >>> # has succeeded ``` ### Server Error Here's an example of when setting a message from the client causes an error. The server nonce and salt is specified in order to give a reproducible example, but in production you'd omit the `s_nonce` and `salt` parameters and let Scramp generate them: ```python >>> from scramp import ScramException, ScramMechanism >>> >>> USERNAME = 'user' >>> PASSWORD = 'pencil' >>> S_NONCE = '%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0' >>> SALT = b'[m\x99h\x9d\x125\x8e\xec\xa0K\x14\x126\xfa\x81' >>> >>> db = {} >>> >>> m = ScramMechanism() >>> >>> salt, stored_key, server_key, iteration_count = m.make_auth_info( ... PASSWORD, salt=SALT) >>> >>> db[USERNAME] = salt, stored_key, server_key, iteration_count >>> >>> # Define your own function for getting a password given a username >>> def auth_fn(username): ... return db[username] >>> >>> # Normally the s_nonce parameter would be omitted, in which case the >>> # server will generate the nonce itself. >>> >>> s = m.make_server(auth_fn, s_nonce=S_NONCE) >>> >>> try: ... # Set the first message from the client ... s.set_client_first('p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO') ... except ScramException as e: ... print(e) ... # Get the final server message and send it to the client ... sfinal = s.get_server_final() ... print(sfinal) Received GS2 flag 'p' which indicates that the client requires channel binding, but the server does not: channel-binding-not-supported e=channel-binding-not-supported ``` ### Standards - [RFC 5802](https://tools.ietf.org/html/rfc5802>) Describes SCRAM. - [RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677>) Registers SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. - [draft-melnikov-scram-sha-512-02](https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha-512) Registers SCRAM-SHA-512 and SCRAM-SHA-512-PLUS. - [draft-melnikov-scram-sha3-512](https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha3-512) Registers SCRAM-SHA3-512 and SCRAM-SHA3-512-PLUS. - [RFC 5929](https://datatracker.ietf.org/doc/html/rfc5929) Channel Bindings for TLS. - [draft-ietf-kitten-tls-channel-bindings-for-tls13](https://datatracker.ietf.org/doc/html/draft-ietf-kitten-tls-channel-bindings-for-tls13>) Defines the `tls-exporter` channel binding, which is [not yet supported by Scramp](https://github.com/tlocke/scramp/issues/9). ## API Docs ### scramp.MECHANISMS A tuple of the supported mechanism names. ### scramp.ScramClient `ScramClient(mechanisms, username, password, channel_binding=None, c_nonce=None)` Constructor of the `scramp.ScramClient` class, with the following parameters: - `mechanisms` - A list or tuple of mechanism names. ScramClient will choose the most secure. If `cbind_data` is `None`, the '-PLUS' variants will be filtered out first. The chosen mechanism is available as the property `mechanism_name`. - `username` - `password` - `channel_binding` - Providing a value for this parameter allows channel binding to be used (ie. it lets you use mechanisms ending in '-PLUS'). The value for `channel_binding` is a tuple consisting of the channel binding name and the channel binding data. For example, if the channel binding name is `tls-unique`, the `channel_binding` parameter would be `('tls-unique', data)`, where `data` is obtained by calling [SSLSocket.get\_channel\_binding()](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding). The convenience function `scramp.make_channel_binding()` can be used to create a channel binding tuple. - `c_nonce` - The client nonce. It's sometimes useful to set this when testing / debugging, but in production this should be omitted, in which case `ScramClient` will generate a client nonce. The `ScramClient` object has the following methods and properties: - `get_client_first()` - Get the client first message. - `set_server_first(message)` - Set the first message from the server. - `get_client_final()` - Get the final client message. - `set_server_final(message)` - Set the final message from the server. - `mechanism_name` - The mechanism chosen from the list given in the constructor. ### scramp.ScramMechanism `ScramMechanism(mechanism='SCRAM-SHA-256')` Constructor of the `ScramMechanism` class, with the following parameter: - `mechanism` - The SCRAM mechanism to use. The `ScramMechanism` object has the following methods and properties: - `make_auth_info(password, iteration_count=None, salt=None)` - Returns the tuple `(salt, stored_key, server_key, iteration_count)` which is stored in the authentication database on the server side. It has the following parameters: - `password` - The user's password as a `str`. - `iteration_count` - The rounds as an `int`. If `None` then use the minimum associated with the mechanism. - `salt` - It's sometimes useful to set this binary parameter when testing / debugging, but in production this should be omitted, in which case a salt will be generated. - `make_server(auth_fn, channel_binding=None, s_nonce=None)` - returns a `ScramServer` object. It takes the following parameters: - `auth_fn` This is a function provided by the programmer that has one parameter, a username of type `str` and returns returns the tuple `(salt, stored_key, server_key, iteration_count)`. Where `salt`, `stored_key` and `server_key` are of a binary type, and `iteration_count` is an `int`. - `channel_binding` - Providing a value for this parameter allows channel binding to be used (ie. it lets you use mechanisms ending in `-PLUS`). The value for `channel_binding` is a tuple consisting of the channel binding name and the channel binding data. For example, if the channel binding name is 'tls-unique', the `channel_binding` parameter would be `('tls-unique', data)`, where `data` is obtained by calling [SSLSocket.get\_channel\_binding()](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding>). The convenience function `scramp.make_channel_binding()` can be used to create a channel binding tuple. If `channel_binding` is provided and the mechanism isn't a `-PLUS` variant, then the server will negotiate with the client to use the `-PLUS` variant if the client supports it, or otherwise to use the mechanism without channel binding. - `s_nonce` - The server nonce as a `str`. It's sometimes useful to set this when testing / debugging, but in production this should be omitted, in which case `ScramServer` will generate a server nonce. - `make_stored_server_keys(salted_password)` - Returns `(stored_key, server_key)` tuple of `bytes` objects given a salted password. This is useful if you want to use a separate hashing implementation from the one provided by Scramp. It takes the following parameter: - `salted_password` - A binary object representing the hashed password. - `iteration_count` - The minimum iteration count recommended for this mechanism. ### scramp.ScramServer The `ScramServer` object has the following methods: - `set_client_first(message)` - Set the first message from the client. - `get_server_first()` - Get the server first message. - `set_client_final(message)` - Set the final client message. - `get_server_final()` - Get the server final message. ### scramp.make\_channel\_binding(name, ssl\_socket) A helper function that makes a `channel_binding` tuple when given a channel binding name and an SSL socket. The parameters are: - `name` - A channel binding name such as 'tls-unique' or 'tls-server-end-point'. - `ssl_socket` - An instance of [ssl.SSLSocket](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket). ## Testing - Activate the virtual environment: `source venv/bin/activate` - Install `tox`: `pip install tox` - Run `tox`: `tox` ## OpenSSF Scorecard It might be worth running the [OpenSSF Scorecard](https://securityscorecards.dev/): `sudo docker run -e GITHUB_AUTH_TOKEN= gcr.io/openssf/scorecard:stable --repo=github.com/tlocke/scramp` ## Doing A Release Of Scramp Run `tox` to make sure all tests pass, then update the release notes, then do: - `git tag -a x.y.z -m "version x.y.z"` - `rm -r dist` - `python -m build` - `twine upload dist/*` ## Release Notes ### Version 1.4.5, 2024-04-13 - Drop support for Python 3.7, which means we're not dependent on `importlib-metadata` anymore. - Various changes to build system, docs and automated testing. ### Version 1.4.4, 2022-11-01 - Tighten up parsing of messages to make sure that a `ScramException` is raised if a message is malformed. ### Version 1.4.3, 2022-10-26 - The client now sends a gs2-cbind-flag of 'y' if the client supports channel binding, but thinks the server does not. ### Version 1.4.2, 2022-10-22 - Switch to using the MIT-0 licence https://choosealicense.com/licenses/mit-0/ - When creating a ScramClient, allow non ``-PLUS`` variants, even if a `channel_binding` parameter is provided. Previously this would raise and exception. ### Version 1.4.1, 2021-08-25 - When using `make_channel_binding()` to create a tls-server-end-point channel binding, support certificates with hash algorithm of sha512. ### Version 1.4.0, 2021-03-28 - Raise an exception if the client receives an error from the server. ### Version 1.3.0, 2021-03-28 - As the specification allows, server errors are now sent to the client in the `server_final` message, an exception is still thrown as before. ### Version 1.2.2, 2021-02-13 - Fix bug in generating the AuthMessage. It was incorrect when channel binding was used. So now Scramp supports channel binding. ### Version 1.2.1, 2021-02-07 - Add support for channel binding. - Add support for SCRAM-SHA-512 and SCRAM-SHA3-512 and their channel binding variants. ### Version 1.2.0, 2020-05-30 - This is a backwardly incompatible change on the server side, the client side will work as before. The idea of this change is to make it possible to have an authentication database. That is, the authentication information can be stored, and then retrieved when needed to authenticate the user. - In addition, it's now possible on the server side to use a third party hashing library such as passlib as the hashing implementation. ### Version 1.1.1, 2020-03-28 - Add the README and LICENCE to the distribution. ### Version 1.1.0, 2019-02-24 - Add support for the SCRAM-SHA-1 mechanism. ### Version 1.0.0, 2019-02-17 - Implement the server side as well as the client side. ### Version 0.0.0, 2019-02-10 - Copied SCRAM implementation from [pg8000](https://github.com/tlocke/pg8000). The idea is to make it a general SCRAM implemtation. Credit to the [Scrampy](https://github.com/cagdass/scrampy) project which I read through to help with this project. Also credit to the [passlib](https://github.com/efficks/passlib) project from which I copied the `saslprep` function.