scramp-1.4.17/src/scramp/__init__.py0000644000000000000000000000044313615410400014177 0ustar00from importlib.metadata import version from scramp.core import ( ScramClient, ScramMechanism, make_channel_binding, ) from scramp.utils import ScramException __all__ = ["ScramClient", "ScramException", "ScramMechanism", "make_channel_binding"] __version__ = version("scramp") scramp-1.4.17/src/scramp/core.py0000644000000000000000000007350513615410400013401 0ustar00import hashlib import unicodedata from enum import IntEnum, unique from functools import wraps from hmac import compare_digest from operator import attrgetter from secrets import token_bytes, token_hex from stringprep import ( in_table_a1, in_table_b1, in_table_c3, in_table_c4, in_table_c5, in_table_c6, in_table_c7, in_table_c8, in_table_c9, in_table_c12, in_table_c21_c22, in_table_d1, in_table_d2, ) from asn1crypto.x509 import Certificate from scramp.utils import ( SERVER_ERROR_CHANNEL_BINDING_NOT_SUPPORTED, SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH, SERVER_ERROR_EXTENSIONS_NOT_SUPPORTED, SERVER_ERROR_INVALID_ENCODING, SERVER_ERROR_INVALID_PROOF, SERVER_ERROR_INVALID_USERNAME_ENCODING, SERVER_ERROR_OTHER_ERROR, SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING, SERVER_ERROR_UNKNOWN_USER, SERVER_ERROR_UNSUPPORTED_CHANNEL_BINDING_TYPE, IterationCount, ScramException, b64dec, b64enc, h, hmac, uenc, xor, ) # https://tools.ietf.org/html/rfc5802 # https://www.rfc-editor.org/rfc/rfc7677.txt class Salt: def __init__(self, salt): if not isinstance(salt, bytes): raise ScramException( f"The 'salt' must be of type bytes, but found type {type(salt)}", SERVER_ERROR_OTHER_ERROR, ) self.salt = salt def __str__(self): return b64enc(self.salt) def __bytes__(self): return self.salt def __eq__(self, other): return isinstance(other, Salt) and self.salt == other.salt @classmethod def from_str(cls, s): try: return cls(b64dec(s)) except ScramException as e: raise ScramException( f"Invalid salt encoding: {e}", SERVER_ERROR_INVALID_ENCODING ) from e @classmethod def create(cls): return cls(token_bytes()) class Nonce: def __init__(self, nonce): if not isinstance(nonce, str): raise ScramException( f"The 'nonce' must be of type str, but found type {type(nonce)}", SERVER_ERROR_OTHER_ERROR, ) if not all(0x21 <= ord(c) <= 0x7E and c != "," for c in nonce): raise ScramException( "Nonce contains invalid characters.", SERVER_ERROR_OTHER_ERROR ) self.nonce = nonce def __str__(self): return self.nonce def __eq__(self, other): return isinstance(other, Nonce) and self.nonce == other.nonce def __add__(self, other): if not isinstance(other, Nonce): return NotImplemented return Nonce(self.nonce + other.nonce) def startswith(self, other): if not isinstance(other, Nonce): return NotImplemented return self.nonce.startswith(other.nonce) @classmethod def create(cls): return cls(token_hex()) @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." ) 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", ) MAX_ITERATION_COUNT = 10_000_000 # DoS guard 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 salt is not None: salt = Salt(salt) try: i_count = self.parse_iteration_count(iteration_count) except ValueError as e: raise ScramException(f"The iteration count is not valid: {e}") from e salt, stored_key, server_key = _make_auth_info( self.hf, password, i_count, salt=salt ) return bytes(salt), stored_key, server_key, i_count def make_server(self, auth_fn, channel_binding=None, s_nonce=None): if s_nonce is not None: s_nonce = Nonce(s_nonce) return ScramServer( self, AuthFn(auth_fn), channel_binding=channel_binding, s_nonce=s_nonce ) def parse_iteration_count(self, i): return IterationCount(i, self.iteration_count, MAX_ITERATION_COUNT) def _make_auth_info(hf, password, i, salt=None): if salt is None: salt = Salt.create() 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( f"The channel_binding parameter must either be None or a tuple with the " f"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, use_binding = mech.hf, mech.use_binding self.mechanism_name = mech.name self.iterations = mech.iteration_count self.c_nonce = Nonce.create() if c_nonce is None else Nonce(c_nonce) self.username = Username(username) self.password = password self.channel_binding = channel_binding self.stage = None self.gs2_header = Gs2Header.from_binding(self.channel_binding, use_binding) 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.gs2_header ) 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, self.iterations ) 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.gs2_header, ) 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 = Nonce.create() if s_nonce is None else s_nonce self.auth_fn = auth_fn self.stage = None self.server_signature = None self.error = None self.nonce = 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.client_first_bare, upgrade_mechanism, self.gs2_header, self.salt, self.stored_key, self.server_key, self.i, ) = _set_client_first( client_first, self.s_nonce, self.channel_binding, self.m.use_binding, self.auth_fn, ) if upgrade_mechanism: mech = ScramMechanism(f"{self.m.name}-PLUS") self._set_mechanism(mech) @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.nonce, self.stored_key, self.server_key, self.client_first_bare, self.server_first, self.channel_binding, self.gs2_header, ) @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_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 hashlib.pbkdf2_hmac( hf().name, uenc(saslprep(password)), bytes(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) try: client_key = xor(client_signature, b64dec(proof)) except ValueError as e: raise ScramException( "Can't create client key.", SERVER_ERROR_INVALID_PROOF ) from e key = h(hf, client_key) if not compare_digest(key, stored_key): raise ScramException("The client keys don't match.", SERVER_ERROR_INVALID_PROOF) class Gs2Header: # gs2-header = gs2-cbind-flag "," [ authzid ] "," def __init__(self, gs2_char, cb_name): self.gs2_char = gs2_char self.cb_name = cb_name def __str__(self): if self.gs2_char == "n": return "n,," elif self.gs2_char == "y": return "y,," elif self.gs2_char == "p": return f"p={self.cb_name},," else: raise ScramException("Invalid GS2 char") def __bytes__(self): return str(self).encode("ascii") def __eq__(self, other): return ( isinstance(other, Gs2Header) and self.gs2_char == other.gs2_char and self.cb_name == other.cb_name ) @classmethod def from_str(cls, s): gs2_header = s.split(",") try: gs2_cbind_flag = gs2_header[0] except IndexError: raise ScramException( "The client sent malformed gs2 data", SERVER_ERROR_OTHER_ERROR, ) if gs2_cbind_flag == "y": gs2_char, cb_name = "y", None elif gs2_cbind_flag == "n": gs2_char, cb_name = "n", None elif gs2_cbind_flag.startswith("p="): gs2_char = "p" cb_name = gs2_cbind_flag.split("=")[-1] for c in cb_name: if not (c.isascii() and (c.isalpha() or c.isdigit() or c in ".-")): raise ScramException( f"The channel binding name {cb_name} is not valid", SERVER_ERROR_OTHER_ERROR, ) else: raise ScramException( f"Received GS2 flag {gs2_cbind_flag} which isn't recognized", SERVER_ERROR_OTHER_ERROR, ) try: authzid = gs2_header[1] except IndexError: raise ScramException( "The client sent malformed gs2 data", SERVER_ERROR_OTHER_ERROR, ) if authzid != "": raise ScramException( f"The GS2 authzid {authzid} must be empty", SERVER_ERROR_OTHER_ERROR, ) return cls(gs2_char, cb_name) @classmethod def from_binding(cls, channel_binding, use_binding): if channel_binding is None: gs2_char, cb_name = "n", None else: if use_binding: channel_type, _ = channel_binding gs2_char, cb_name = "p", channel_type else: gs2_char, cb_name = "y", None return cls(gs2_char, cb_name) def _make_cbind_input(channel_binding, gs2_header): gs2_header_bin = bytes(gs2_header) if gs2_header.gs2_char in ("y", "n"): return gs2_header_bin elif gs2_header.gs2_char == "p": _, cbind_data = channel_binding return gs2_header_bin + cbind_data def _print_set(s): return "{" + ", ".join(sorted(set(s))) + "}" PROTO_ATTRS = {"a", "n", "m", "r", "c", "s", "i", "p", "v", "e"} def _parse_message(msg, desc, *expected_attr_sets): 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, ) k = p[0] if not (k.isalpha() and k.isascii()): raise ScramException( f"Malformed {desc} message. Attributes must be US-ASCII alpha " f"characters.", SERVER_ERROR_OTHER_ERROR, ) elif k == "m": raise ScramException( "The 'm' attribute isn't supported by this version of the protocol.", SERVER_ERROR_EXTENSIONS_NOT_SUPPORTED, ) elif k in m: raise ScramException( f"Duplicate attributes not allowed in message. The duplicated " f"attribute is {k}. ", SERVER_ERROR_OTHER_ERROR, ) elif k not in PROTO_ATTRS: # Optional extensions ignored continue v = p[2:] if v == "": raise ScramException( f"Malformed {desc} message. Attribute values must at " f"least one character long", SERVER_ERROR_OTHER_ERROR, ) elif "\x00" in v: raise ScramException( f"Malformed {desc} message. Attribute values can't " f"contain the NUL character", SERVER_ERROR_OTHER_ERROR, ) elif "," in v: raise ScramException( f"Malformed {desc} message. Attribute values can't " f"contain the ',' character", SERVER_ERROR_OTHER_ERROR, ) m[k] = v attr_set = m.keys() if attr_set in expected_attr_sets: return m raise ScramException( f"Malformed {desc} message. Expected the attribute set to be one of " f"[{', '.join([_print_set(s) for s in expected_attr_sets])}] but found " f"{_print_set(attr_set)}", SERVER_ERROR_OTHER_ERROR, ) class Username: def __init__(self, username): try: self.username = saslprep(username) except ScramException as e: raise ScramException(e.args[0], SERVER_ERROR_INVALID_USERNAME_ENCODING) def __str__(self): return self.username def __eq__(self, other): return isinstance(other, Username) and self.username == other.username def escape(self): return _username_escape(self.username) @classmethod def from_escaped(cls, username): return cls(_username_unescape(username)) ESCAPE_EQUALS = "=3D" ESCAPE_COMMA = "=2C" def _username_escape(username): return username.replace("=", ESCAPE_EQUALS).replace(",", ESCAPE_COMMA) def _username_unescape(username): for i in (i for i, c in enumerate(username) if c == "="): if username[i : i + 3] not in (ESCAPE_EQUALS, ESCAPE_COMMA): raise ScramException( "An '=' in a username must be followed by '3D', or '2C'", SERVER_ERROR_INVALID_USERNAME_ENCODING, ) return username.replace(ESCAPE_COMMA, ",").replace(ESCAPE_EQUALS, "=") class AuthFn: def __init__(self, auth_fn): if not callable(auth_fn): raise ScramException( "The 'auth_fn' must be callable", SERVER_ERROR_OTHER_ERROR ) self.auth_fn = auth_fn def __call__(self, username): try: salt, stored_key, server_key, i = self.auth_fn(str(username)) # pyright: ignore[reportGeneralTypeIssues] except BaseException as e: raise ScramException("Unknown user", SERVER_ERROR_UNKNOWN_USER) from e if not isinstance(stored_key, bytes): raise ScramException( f"The 'stored_key' must be of type bytes, " f"but found type {type(stored_key)}", SERVER_ERROR_OTHER_ERROR, ) if not isinstance(server_key, bytes): raise ScramException( f"The 'server_key' must be of type bytes, " f"but found type {type(server_key)}", SERVER_ERROR_OTHER_ERROR, ) return ( Salt(salt), stored_key, server_key, IterationCount(i, 1, MAX_ITERATION_COUNT), ) def _get_client_first(username, c_nonce, gs2_header): bare = ",".join((f"n={username.escape()}", f"r={c_nonce}")) return bare, str(gs2_header) + bare def _set_client_first(client_first, s_nonce, channel_binding, use_binding, auth_fn): 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 = Gs2Header.from_str(client_first[:second_comma]) upgrade_mechanism = False if gs2_header.gs2_char == "y": if channel_binding is not None: raise ScramException( "Received 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_header.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_header.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_header.cb_name 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, ) client_first_bare = client_first[second_comma + 1 :] msg = _parse_message(client_first_bare, "client first bare", {"n", "r"}) c_nonce = Nonce(msg["r"]) nonce = c_nonce + s_nonce user = Username.from_escaped(msg["n"]) salt, stored_key, server_key, i = auth_fn(user) return ( nonce, client_first_bare, upgrade_mechanism, gs2_header, salt, stored_key, server_key, i, ) 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, min_iteration_count): msg = _parse_message(server_first, "server first", {"r", "s", "i"}, {"e"}) if "e" in msg: raise ScramException(f"The server returned the error: {msg['e']}") nonce = Nonce(msg["r"]) salt = Salt.from_str(msg["s"]) iteration_count = msg["i"] try: iterations = IterationCount( int(iteration_count), min_iteration_count, MAX_ITERATION_COUNT ) except ValueError as e: raise ScramException( f"Server iteration count {iteration_count} is not valid" ) from e 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, iterations, nonce, client_first_bare, server_first, channel_binding, gs2_header, ): 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, gs2_header) 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 def _set_client_final( hf, client_final, nonce, stored_key, server_key, client_first_bare, server_first, channel_binding, gs2_header, ): msg = _parse_message(client_final, "client final", {"c", "r", "p"}) chan_binding = msg["c"] try: chan_binding_bin = b64dec(chan_binding) except ScramException as e: raise ScramException( f"The channel binding isn't correctly encoded: {e}", SERVER_ERROR_INVALID_ENCODING, ) from e msg_nonce = Nonce(msg["r"]) proof = msg["p"] if not compare_digest( chan_binding_bin, _make_cbind_input(channel_binding, gs2_header), # type: ignore[arg-type] ): raise ScramException( "The channel bindings don't match.", SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH, ) if not nonce == msg_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 not compare_digest(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: for f, msg in ( # check for chars mapping stage should have removed (in_table_b1, "failed to strip B.1 in mapping stage"), (in_table_c12, "failed to replace C.1.2 in mapping stage"), # check for forbidden chars (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.17/src/scramp/utils.py0000644000000000000000000000371413615410400013604 0ustar00import hmac as hmaca from base64 import b64decode, b64encode SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" SERVER_ERROR_EXTENSIONS_NOT_SUPPORTED = "extensions-not-supported" SERVER_ERROR_INVALID_PROOF = "invalid-proof" SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH = "channel-bindings-dont-match" SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING = "server-does-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" 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 def hmac(hf, key, msg): return hmaca.new(key, msg=msg, digestmod=hf).digest() def h(hf, msg): return hf(msg).digest() def xor(bytes1, bytes2): return bytes(a ^ b for a, b in zip(bytes1, bytes2, strict=True)) def b64enc(binary): return b64encode(binary).decode("utf8") def b64dec(string): try: return b64decode(string, validate=True) except BaseException as e: raise ScramException( f"Invalid base 64 encoding '{string}'", SERVER_ERROR_INVALID_ENCODING ) from e def uenc(string): return string.encode("utf-8") class IterationCount(int): def __new__(cls, value, minimum, maximum): if value is None: value = minimum if value < minimum: raise ValueError(f"The value must not be < {minimum}") if value > maximum: raise ValueError(f"The value must not be > {maximum}") return super().__new__(cls, value) scramp-1.4.17/test/test_import_all.py0000644000000000000000000000011213615410400014535 0ustar00from scramp import * # noqa: F403,F401 def test_import_all(): pass scramp-1.4.17/test/test_readme.py0000644000000000000000000000033513615410400013637 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.17/test/test_scramp.py0000644000000000000000000007765613615410400013713 0ustar00import hashlib import pytest from scramp import ( ScramClient, ScramException, ScramMechanism, core, make_channel_binding, ) from scramp.core import ( SERVER_ERROR_INVALID_PROOF, AuthFn, Gs2Header, Nonce, Salt, Username, _check_client_key, _get_client_final, _parse_message, _set_client_final, _set_client_first, _set_server_first, _username_unescape, _validate_channel_binding, ) from scramp.utils import b64dec, b64enc @pytest.mark.parametrize( "msg,att_sets,error_msg", [ [ "", [{"a", "b", "c"}], "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", [{"a", "b", "c"}], "Malformed trial message. Expected the attribute set to be one of " "[{a, b, c}] but found {c}: other-error", ], [ "c=jk,c=kln", [{"c"}], "Duplicate attributes not allowed in message. The duplicated attribute " "is c. : other-error", ], [ "e=error", [{"c"}], "Malformed trial message. Expected the attribute set to be one of [{c}] " "but found {e}: other-error", ], [ "e=\x00", [{"e"}], "Malformed trial message. Attribute values can't contain the NUL " "character: other-error", ], ], ) def test_parse_message_fail(msg, att_sets, error_msg): with pytest.raises(ScramException) as exc_info: _parse_message(msg, "trial", *att_sets) assert str(exc_info.value) == error_msg @pytest.mark.parametrize( "msg,att_sets,result", [ ["c=jk,i=kln", [{"c", "i"}], {"c": "jk", "i": "kln"}], ["c=jk,i=kln", [{"a", "b", "c"}, {"c", "i"}], {"c": "jk", "i": "kln"}], ["c=b", [{"c"}], {"c": "b"}], ["k=k,c=b", [{"c"}], {"c": "b"}], ], ) def test_parse_message_succeed(msg, att_sets, result): assert _parse_message(msg, "trial", *att_sets) == result @pytest.mark.parametrize( "cb,msg", [ [ ("c", "d"), "The channel_binding parameter must either be None or a " "tuple with the first element a str specifying one of the channel types " "('tls-server-end-point', 'tls-unique', 'tls-unique-for-telnet').", ], ], ) def test_validate_channel_binding_fail(cb, msg): with pytest.raises(ScramException) as exc_info: _validate_channel_binding(cb) assert str(exc_info.value) == msg @pytest.mark.parametrize( "username, expected_username", [ # RFC 5802, section 5.1 ["u=3Dse=2Cr", "u=se,r"], ], ) def test_username_unescape(username, expected_username): actual_username = _username_unescape(username) assert actual_username == expected_username @pytest.mark.parametrize( "username,error_msg,server_error", [ # RFC 5802, section 5.1 [ "=", "An '=' in a username must be followed by '3D', or '2C': " "invalid-username-encoding", "invalid-username-encoding", ], ], ) def test_username_unescape_error(username, error_msg, server_error): with pytest.raises(ScramException) as exc_info: _username_unescape(username) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error @pytest.mark.parametrize( "gs2_char,cb_name,expected", [ ["p", "aname", "p=aname,,"], ], ) def test_Gs2Header_str(gs2_char, cb_name, expected): gs2_header = Gs2Header(gs2_char, cb_name) assert str(gs2_header) == expected @pytest.mark.parametrize( "gs2_char,cb_name", [ ["p", "aname"], ], ) def test_Gs2Header_eq(gs2_char, cb_name): gs2_header_a = Gs2Header(gs2_char, cb_name) gs2_header_b = Gs2Header(gs2_char, cb_name) assert gs2_header_a == gs2_header_b @pytest.mark.parametrize( "salt,error_msg,server_error", [ [ "", # Must be bytes "The 'salt' must be of type bytes, but found type : " "other-error", "other-error", ], ], ) def test_Salt_init_error(salt, error_msg, server_error): with pytest.raises(ScramException) as exc_info: Salt(salt) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error @pytest.mark.parametrize( "salt_str,error_msg,server_error", [ [ "!!!", "Invalid salt encoding: Invalid base 64 encoding '!!!': " "invalid-encoding: invalid-encoding", "invalid-encoding", ], ], ) def test_Salt_from_str_error(salt_str, error_msg, server_error): with pytest.raises(ScramException) as exc_info: Salt.from_str(salt_str) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error @pytest.mark.parametrize( "nonce,error_msg,server_error", [ [ b"", # Must be str "The 'nonce' must be of type str, but found type : " "other-error", "other-error", ], ], ) def test_Nonce_init_error(nonce, error_msg, server_error): with pytest.raises(ScramException) as exc_info: Nonce(nonce) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error def test_AuthFn_init_callable(): class Auth: def __call__(self, _): pass AuthFn(Auth()) @pytest.mark.parametrize( "password,iteration_count,salt,msg", [ [ "pencil", 1, b"a", "The iteration count is not valid: The value must not be < 10000", ], [ "pencil", 20000, "", "The 'salt' must be of type bytes, but found type " ": other-error", ], ], ) def test_make_auth_info_fail(password, iteration_count, salt, msg): m = ScramMechanism(mechanism="SCRAM-SHA3-512") with pytest.raises(ScramException) as exc_info: m.make_auth_info(password, iteration_count, salt) assert str(exc_info.value) == msg @pytest.mark.parametrize( "hf,stored_key, auth_msg, proof, msg", [ # Client signature and proof of different lengths, so xor() should fail [ hashlib.sha256, b64dec( "7tmSwbz0qdlCWaMqA8gm8gNQ3VHbW1zEKpX+ST1QX5RzBefTHhYe3" "EtogaGggZioWX1pp471+gbmGOn31w5iTg==" ), b"n=user,r=fyko+d2lbbFgONRv9qkxdawL,", "W22ZaJ0SNY7soEsUEjb6gQ==", "Can't create client key.: invalid-proof", ], ], ) def test_check_client_key_fail(hf, stored_key, auth_msg, proof, msg): with pytest.raises(ScramException) as exc_info: _check_client_key(hf, stored_key, auth_msg, proof) assert str(exc_info.value) == msg assert str(exc_info.value.server_error) == SERVER_ERROR_INVALID_PROOF 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, # Standard SCRAM_SHA_1 with username that needs escaping { "username": "u=se,r", "password": "pencil", "c_mechanisms": ["SCRAM-SHA-1"], "s_mechanism": "SCRAM-SHA-1", "cfirst": "n,,n=u=3Dse=2Cr,r=fyko+d2lbbFgONRv9qkxdawL", "cfirst_bare": "n=u=3Dse=2Cr,r=fyko+d2lbbFgONRv9qkxdawL", "sfirst": "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "s=QSXCR+Q6sek8bf92,i=4096", "cfinal": "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "p=vqBTKFd8G1j1sueD4sUotSjIYfs=", "cfinal_without_proof": "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "sfinal": "v=ySs7A8qkVwzSKc4pAqR/R4+/50g=", "c_nonce": "fyko+d2lbbFgONRv9qkxdawL", "s_nonce": "3rfcNHYJY1ZVvWVs7j", "nonce": "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "auth_message": b"n=u=3Dse=2Cr,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": "ySs7A8qkVwzSKc4pAqR/R4+/50g=", "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_SHA3_512 { "username": "user", "password": "pencil", "c_mechanisms": ["SCRAM-SHA3-512"], "s_mechanism": "SCRAM-SHA3-512", "cfirst": "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", "cfirst_bare": "n=user,r=fyko+d2lbbFgONRv9qkxdawL", "sfirst": "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "s=QSXCR+Q6sek8bf92,i=10000", "cfinal": "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j," "p=KOkd92LduC09A+RDxbTvgxH9Nn6efom/uAy6U5/fqpwLH1J+wQnZcKx5W1zd" "7YMPU8PrusBUK5RgRk4yHx+3Mg==", "cfinal_without_proof": "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "sfinal": "v=L8sKlyFigUkpTO8I3eaJSyuQhsDynb2eD1MWl+2ELjw7fJkbzr" "8N6Z41pkOAfjzuW/sT6+UElBTt5WbaxZ8oag==", "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=10000,c=biws," b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j", "salt": "QSXCR+Q6sek8bf92", "iterations": 10000, "server_signature": "L8sKlyFigUkpTO8I3eaJSyuQhsDynb2eD1MWl+" "2ELjw7fJkbzr8N6Z41pkOAfjzuW/sT6+UElBTt5WbaxZ8oag==", "hf": hashlib.sha3_512, "stored_key": "7tmSwbz0qdlCWaMqA8gm8gNQ3VHbW1zEKpX+ST1QX5RzBefTHhYe3" "EtogaGggZioWX1pp471+gbmGOn31w5iTg==", "server_key": "lLR0hmplzlAmeKBf3SO/jzdaPse5fUr+phiGcjHEq84uBSsC" "yaP21OIWheSKAGSIRiXVztaC3hBde0ZM/Ae/Ug==", "c_use_binding": False, "s_init_use_binding": False, "s_use_binding": False, "c_channel_binding": None, "s_channel_binding": None, }, ] @pytest.mark.parametrize("x", params) def test_get_client_first(x): gs2_header = Gs2Header.from_binding(x["c_channel_binding"], x["c_use_binding"]) cfirst_bare, cfirst = core._get_client_first( Username(x["username"]), x["c_nonce"], gs2_header ) 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): gs2_header = Gs2Header.from_binding(x["c_channel_binding"], x["c_use_binding"]) salt = Salt.from_str(x["salt"]) server_signature, cfinal = _get_client_final( x["hf"], x["password"], salt, x["iterations"], x["nonce"], x["cfirst_bare"], x["sfirst"], x["c_channel_binding"], gs2_header, ) 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): m = ScramMechanism(mechanism=x["s_mechanism"]) salt_in = Salt.from_str(x["salt"]) def auth_fn(username): lookup = { x["username"]: m.make_auth_info( x["password"], salt=bytes(salt_in), iteration_count=x["iterations"], ) } return lookup[username] ( nonce, cfirst_bare, upgrade_mechanism, gs2_header, salt, stored_key, server_key, i, ) = _set_client_first( x["cfirst"], Nonce(x["s_nonce"]), x["s_channel_binding"], x["s_init_use_binding"], AuthFn(auth_fn), ) assert nonce == Nonce(x["nonce"]) assert cfirst_bare == x["cfirst_bare"] assert upgrade_mechanism == (x["s_init_use_binding"] != x["s_use_binding"]) expected_gs2_header = Gs2Header.from_binding( x["c_channel_binding"], x["c_use_binding"] ) assert gs2_header == expected_gs2_header assert salt == salt_in assert b64enc(stored_key) == x["stored_key"] assert b64enc(server_key) == x["server_key"] assert i == x["iterations"] @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): gs2_header = Gs2Header.from_binding(x["c_channel_binding"], x["c_use_binding"]) server_signature = core._set_client_final( x["hf"], x["cfinal"], Nonce(x["nonce"]), b64dec(x["stored_key"]), b64dec(x["server_key"]), x["cfirst_bare"], x["sfirst"], x["s_channel_binding"], gs2_header, ) 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, ) @pytest.mark.parametrize( "client_first,s_nonce,channel_binding,use_binding,error_msg,server_error", [ # Client requires channel binding but the server doesn't [ "p=tls-unique,,n=user,r=rOprNGfwEbeRWgbNEkqO", Nonce("%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0"), None, False, "Received GS2 flag 'p' which indicates that the client " "requires channel binding, but the server does not: " "channel-binding-not-supported", "channel-binding-not-supported", ], # Client first is invalid [ "junk", Nonce("%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0"), None, False, "The client sent a malformed first message: other-error", "other-error", ], # Client first bare message malformed [ "n,,junk", Nonce("%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0"), None, False, "Malformed client first bare message. Attributes must be separated by a " "',' and each attribute must start with a letter followed by a '=': " "other-error", "other-error", ], # authzid must be empty [ "n,anid,n=user,r=rOprNGfwEbeRWgbNEkqO", Nonce("%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0"), None, False, "The GS2 authzid anid must be empty: other-error", "other-error", ], # gs2-cbind-flag must be valid [ "n=invalid,,n=user,r=rOprNGfwEbeRWgbNEkqO", Nonce("%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0"), None, False, "Received GS2 flag n=invalid which isn't recognized: other-error", "other-error", ], ], ) def test_set_client_first_error( client_first, s_nonce, channel_binding, use_binding, error_msg, server_error ): salt, stored_key, server_key = b"a", b"b", b"c" def auth_fn(username): lookup = {"user": (salt, stored_key, server_key)} return lookup[username] with pytest.raises(ScramException) as exc_info: _set_client_first(client_first, s_nonce, channel_binding, use_binding, auth_fn) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error def test_set_client_first_error_auth_fn(): def auth_fn(_): raise Exception() with pytest.raises(ScramException) as exc_info: _set_client_first( "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", Nonce("%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0"), None, False, AuthFn(auth_fn), ) assert str(exc_info.value) == "Unknown user: unknown-user" assert str(exc_info.value.server_error) == "unknown-user" @pytest.mark.parametrize( "hf,client_final,nonce,stored_key,server_key,client_first_bare,server_first," "channel_binding,use_binding,error_msg,server_error", # Malformed client final message [ [ hashlib.sha256, "junk", "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", b64dec("WG5d8oPm3OtcPnkdi4Uo7BkeZkBFzpcXkuLmtbsT4qY="), b64dec("wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU="), "n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", None, Gs2Header("n", None), "Malformed client final message. Attributes must be separated by a ',' " "and each attribute must start with a letter followed by a '=': " "other-error", "other-error", ], # Invalid client final [ hashlib.sha256, "c=biws,r=invalid,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", b64dec("WG5d8oPm3OtcPnkdi4Uo7BkeZkBFzpcXkuLmtbsT4qY="), b64dec("wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU="), "n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", None, Gs2Header("n", None), "Server nonce doesn't match.: other-error", "other-error", ], # Channel has invalid base 64 encoding, so b64dec() should fail [ hashlib.sha256, "c=!!!,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", "%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", b64dec("WG5d8oPm3OtcPnkdi4Uo7BkeZkBFzpcXkuLmtbsT4qY="), b64dec("wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU="), "n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", None, Gs2Header("n", None), "The channel binding isn't correctly encoded: Invalid base 64 " "encoding '!!!': invalid-encoding: invalid-encoding", "invalid-encoding", ], # Even if channel binding isn't used, check it's valid [ hashlib.sha256, "c=rOprNGfwEbeRWgbNEkqO," "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", "rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0", b64dec("WG5d8oPm3OtcPnkdi4Uo7BkeZkBFzpcXkuLmtbsT4qY="), b64dec("wfPLwcE6nTWhTAmQ7tl2KeoiWGPlZqQxSrmfPwDl2dU="), "n=user,r=rOprNGfwEbeRWgbNEkqO", "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", None, Gs2Header("n", None), "The channel bindings don't match.: channel-bindings-dont-match", "channel-bindings-dont-match", ], ], ) def test_set_client_final_error( hf, client_final, nonce, stored_key, server_key, client_first_bare, server_first, channel_binding, use_binding, error_msg, server_error, ): with pytest.raises(ScramException) as exc_info: _set_client_final( hf, client_final, nonce, stored_key, server_key, client_first_bare, server_first, channel_binding, use_binding, ) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error @pytest.mark.parametrize( "server_first,c_nonce,min_iteration_count,error_msg", [ # Error from server [ "e=other-error", "fyko+d2lbbFgONRv9qkxdawL", 1000, "The server returned the error: other-error", ], # Malformed server first [ "junk", "fyko+d2lbbFgONRv9qkxdawL", 1000, "Malformed server first message. Attributes must be separated by a ',' " "and each attribute must start with a letter followed by a '=': " "other-error", ], # Malformed iteration count in server first [ "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=not_an_integer", "fyko+d2lbbFgONRv9qkxdawL", 1000, "Server iteration count not_an_integer is not valid", ], ], ) def test_set_server_first_error(server_first, c_nonce, min_iteration_count, error_msg): with pytest.raises(ScramException) as exc_info: _set_server_first(server_first, c_nonce, min_iteration_count) assert str(exc_info.value) == error_msg 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_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.17/test/test_utils.py0000644000000000000000000000141713615410400013544 0ustar00import pytest from scramp.utils import ScramException, b64dec, xor @pytest.mark.parametrize( "string,error_msg,server_error", [ [ "!!!!", "Invalid base 64 encoding '!!!!': invalid-encoding", "invalid-encoding", ], ], ) def test_b64dec_fails(string, error_msg, server_error): with pytest.raises(ScramException) as exc_info: b64dec(string) assert str(exc_info.value) == error_msg assert str(exc_info.value.server_error) == server_error @pytest.mark.parametrize( "a,b,msg", [ [b"", b"a", "zip() argument 2 is longer than argument 1"], ], ) def test_xor_fails(a, b, msg): with pytest.raises(ValueError) as exc_info: xor(a, b) assert str(exc_info.value) == msg scramp-1.4.17/.gitignore0000644000000000000000000000230613615410400012002 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.17/LICENSE0000644000000000000000000000161213615410400011016 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.17/README.md0000644000000000000000000005144713615410400011303 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 ## Table Of Contents * [Installation](#installation) * [Examples](#examples) * [Client and Server](#client-and-server) * [Client only](#client-only) * [Server only](#server-only) * [Server Error](#server-error) * [Standards](#standards) * [Missing `tls-exporter` channel binding support](#missing-tls-exporter-channel-binding-support) * [API Docs](#api-docs) * [scramp.MECHANISMS](#scrampmechanisms) * [scramp.ScramClient](#scrampscramclient) * [scramp.ScramMechanism](#scrampscrammechanism) * [scramp.ScramServer](#scrampscramserver) * [scramp.make\_channel\_binding(name, ssl\_socket)](#scrampmake_channel_bindingname-ssl_socket) * [Testing](#testing) * [OpenSSF Scorecard](#openssf-scorecard) * [Doing A Release Of Scramp](#doing-a-release-of-scramp) * [Release Notes](#release-notes) * [Version 1.4.17, 2026-08-07](#version-1417-2026-08-07) * [Version 1.4.16, 2026-08-06](#version-1416-2026-08-06) * [Version 1.4.15, 2026-07-26](#version-1415-2026-07-26) * [Version 1.4.14, 2026-07-25](#version-1414-2026-07-25) * [Version 1.4.13, 2026-07-23](#version-1413-2026-07-23) * [Version 1.4.12, 2026-07-05](#version-1412-2026-07-05) * [Version 1.4.11, 2026-07-04](#version-1411-2026-07-04) * [Version 1.4.10, 2026-06-27](#version-1410-2026-06-27) * [Version 1.4.9, 2026-06-19](#version-149-2026-06-19) * [Version 1.4.8, 2026-01-06](#version-148-2026-01-06) * [Version 1.4.7, 2026-01-04](#version-147-2026-01-04) * [Version 1.4.6, 2025-07-05](#version-146-2025-07-05) * [Version 1.4.5, 2024-04-13](#version-145-2024-04-13) * [Version 1.4.4, 2022-11-01](#version-144-2022-11-01) * [Version 1.4.3, 2022-10-26](#version-143-2022-10-26) * [Version 1.4.2, 2022-10-22](#version-142-2022-10-22) * [Version 1.4.1, 2021-08-25](#version-141-2021-08-25) * [Version 1.4.0, 2021-03-28](#version-140-2021-03-28) * [Version 1.3.0, 2021-03-28](#version-130-2021-03-28) * [Version 1.2.2, 2021-02-13](#version-122-2021-02-13) * [Version 1.2.1, 2021-02-07](#version-121-2021-02-07) * [Version 1.2.0, 2020-05-30](#version-120-2020-05-30) * [Version 1.1.1, 2020-03-28](#version-111-2020-03-28) * [Version 1.1.0, 2019-02-24](#version-110-2019-02-24) * [Version 1.0.0, 2019-02-17](#version-100-2019-02-17) * [Version 0.0.0, 2019-02-10](#version-000-2019-02-10) ## 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 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://codeberg.org/tlocke/scramp/issues/9). ## Missing `tls-exporter` channel binding support RFC 9266 defines `tls-exporter` as the channel binding type for TLS 1.3, using the keying-material exporter from [RFC 5705](https://www.rfc-editor.org/rfc/rfc5705) (TLS ≤1.2) / [RFC 8446 §7.5](https://www.rfc-editor.org/rfc/rfc8446#section-7.5) (TLS 1.3), i.e. OpenSSL's `SSL_export_keying_material()`. Scramp can't implement `tls-exporter` because Python doesn't support it in [`SSLSocket.get_channel_binding()`](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding). Only the older `tls-unique`/`tls-server-end-point` are supported. * Relevant Python issue: [python/cpython#25255](https://github.com/python/cpython/pull/25255) * Relevant [Scramp issue](https://codeberg.org/tlocke/scramp/issues/9). Once `tls-exporter` is supported by `SSLSocket.get_channel_binding()`, we can support `tls-exporter` in Scramp. ## 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. - `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. Note that `ScramServer` doesn't enforce a maximum message or field length, so that's something you might want to consider doing if you're embedding it in a network-facing server. ### 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/): `podman 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.17, 2026-08-07 - Allow `auth_fn` to be any callable. ### Version 1.4.16, 2026-08-06 - Encapsulate `auth_fn` with `AuthFn`, a class that parses the output. ### Version 1.4.15, 2026-07-26 - Check message attribute values don't have invalid characters. ### Version 1.4.14, 2026-07-25 - Make sure all Salt and Nonce errors can be handled with the protocol. - Add note in README on tls-exporter. ### Version 1.4.13, 2026-07-23 - Have a Nonce class for validation. - Have a Salt class for validation. - Handle exceptions from the auth function. - Optional extensions should be ignored. - Check for invalid GS2 header. ### Version 1.4.12, 2026-07-05 - Check for invalid escape chars in username. ### Version 1.4.11, 2026-07-04 - Make sure all errors detected by the server get returned to the client as valid errors. ### Version 1.4.10, 2026-06-27 - Implement max and min guards for iteration count. - The xor() function should fail if the two values are of unequal lengths - Handle the case of an error in first server message - Fix formatting in channel binding error message - SHA3-512 didn't work, but it should do now ### Version 1.4.9, 2026-06-19 - Fix typo in error message. * Strictly validate on base 64 decoding * Handle escaping in usernames according to the spec. * [SASLprep](https://www.rfc-editor.org/info/rfc8265/): Raise exception rather than using assert ### Version 1.4.8, 2026-01-06 - Use the [`secrets`](https://docs.python.org/3/library/secrets.html) module from the Standard Library for generating the salt and nonce. ### Version 1.4.7, 2026-01-04 - The main change in this release is that we now use [`hashlib.pbkdf2_hmac()`](https://docs.python.org/3/library/hashlib.html#hashlib.pbkdf2_hmac) from the standard library, rather than our own Python implementation. Since this is fast, we no longer need to provide a way of using a custom key derivation function, and so `make_stored_server_keys()` has been removed. - To avoid timing attacks we now use [`hmac.compare_digest()`](https://docs.python.org/3/library/hmac.html#hmac.compare_digest) for digest comparisons. ### Version 1.4.6, 2025-07-05 - Drop support for Python 3.8, and add support for 3.13 ### 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.17/pyproject.toml0000644000000000000000000000326713615410400012735 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.10" 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 :: Implementation", "Programming Language :: Python :: Implementation :: CPython", "Operating System :: OS Independent", ] dependencies = ["asn1crypto >= 1.5.1"] dynamic = ["version"] [project.urls] Homepage = "https://codeberg.org/tlocke/scramp" [tool.hatch.version] source = "versioningit" [tool.versioningit] [tool.versioningit.vcs] method = "git" default-tag = "0.0.0" [tool.ruff.lint] extend-select = ["E501", "I"] [tool.ruff.lint.isort] known-first-party = ["scramp"] [tool.tox] requires = ["tox>=4.19"] envlist = ["py"] [tool.tox.env_run_base] description = "Run test under {base_python}" allowlist_externals = ["rm"] commands = [ [ "ruff", "format", "--check", ".", ], [ "validate-pyproject", "pyproject.toml", ], [ "pytest", "-v", "-x", "-W error", "test", ], [ "rm", "-rf", "dist", ], [ "python", "-m", "build", ], [ "twine", "check", "dist/*", ], ] deps = [ "pytest", "pytest-mock", "ruff", "docutils", "build", "twine", "validate-pyproject", ] scramp-1.4.17/PKG-INFO0000644000000000000000000005312413615410400011113 0ustar00Metadata-Version: 2.4 Name: scramp Version: 1.4.17 Summary: An implementation of the SCRAM protocol. Project-URL: Homepage, https://codeberg.org/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 :: Implementation Classifier: Programming Language :: Python :: Implementation :: CPython Requires-Python: >=3.10 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 ## Table Of Contents * [Installation](#installation) * [Examples](#examples) * [Client and Server](#client-and-server) * [Client only](#client-only) * [Server only](#server-only) * [Server Error](#server-error) * [Standards](#standards) * [Missing `tls-exporter` channel binding support](#missing-tls-exporter-channel-binding-support) * [API Docs](#api-docs) * [scramp.MECHANISMS](#scrampmechanisms) * [scramp.ScramClient](#scrampscramclient) * [scramp.ScramMechanism](#scrampscrammechanism) * [scramp.ScramServer](#scrampscramserver) * [scramp.make\_channel\_binding(name, ssl\_socket)](#scrampmake_channel_bindingname-ssl_socket) * [Testing](#testing) * [OpenSSF Scorecard](#openssf-scorecard) * [Doing A Release Of Scramp](#doing-a-release-of-scramp) * [Release Notes](#release-notes) * [Version 1.4.17, 2026-08-07](#version-1417-2026-08-07) * [Version 1.4.16, 2026-08-06](#version-1416-2026-08-06) * [Version 1.4.15, 2026-07-26](#version-1415-2026-07-26) * [Version 1.4.14, 2026-07-25](#version-1414-2026-07-25) * [Version 1.4.13, 2026-07-23](#version-1413-2026-07-23) * [Version 1.4.12, 2026-07-05](#version-1412-2026-07-05) * [Version 1.4.11, 2026-07-04](#version-1411-2026-07-04) * [Version 1.4.10, 2026-06-27](#version-1410-2026-06-27) * [Version 1.4.9, 2026-06-19](#version-149-2026-06-19) * [Version 1.4.8, 2026-01-06](#version-148-2026-01-06) * [Version 1.4.7, 2026-01-04](#version-147-2026-01-04) * [Version 1.4.6, 2025-07-05](#version-146-2025-07-05) * [Version 1.4.5, 2024-04-13](#version-145-2024-04-13) * [Version 1.4.4, 2022-11-01](#version-144-2022-11-01) * [Version 1.4.3, 2022-10-26](#version-143-2022-10-26) * [Version 1.4.2, 2022-10-22](#version-142-2022-10-22) * [Version 1.4.1, 2021-08-25](#version-141-2021-08-25) * [Version 1.4.0, 2021-03-28](#version-140-2021-03-28) * [Version 1.3.0, 2021-03-28](#version-130-2021-03-28) * [Version 1.2.2, 2021-02-13](#version-122-2021-02-13) * [Version 1.2.1, 2021-02-07](#version-121-2021-02-07) * [Version 1.2.0, 2020-05-30](#version-120-2020-05-30) * [Version 1.1.1, 2020-03-28](#version-111-2020-03-28) * [Version 1.1.0, 2019-02-24](#version-110-2019-02-24) * [Version 1.0.0, 2019-02-17](#version-100-2019-02-17) * [Version 0.0.0, 2019-02-10](#version-000-2019-02-10) ## 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 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://codeberg.org/tlocke/scramp/issues/9). ## Missing `tls-exporter` channel binding support RFC 9266 defines `tls-exporter` as the channel binding type for TLS 1.3, using the keying-material exporter from [RFC 5705](https://www.rfc-editor.org/rfc/rfc5705) (TLS ≤1.2) / [RFC 8446 §7.5](https://www.rfc-editor.org/rfc/rfc8446#section-7.5) (TLS 1.3), i.e. OpenSSL's `SSL_export_keying_material()`. Scramp can't implement `tls-exporter` because Python doesn't support it in [`SSLSocket.get_channel_binding()`](https://docs.python.org/3/library/ssl.html#ssl.SSLSocket.get_channel_binding). Only the older `tls-unique`/`tls-server-end-point` are supported. * Relevant Python issue: [python/cpython#25255](https://github.com/python/cpython/pull/25255) * Relevant [Scramp issue](https://codeberg.org/tlocke/scramp/issues/9). Once `tls-exporter` is supported by `SSLSocket.get_channel_binding()`, we can support `tls-exporter` in Scramp. ## 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. - `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. Note that `ScramServer` doesn't enforce a maximum message or field length, so that's something you might want to consider doing if you're embedding it in a network-facing server. ### 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/): `podman 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.17, 2026-08-07 - Allow `auth_fn` to be any callable. ### Version 1.4.16, 2026-08-06 - Encapsulate `auth_fn` with `AuthFn`, a class that parses the output. ### Version 1.4.15, 2026-07-26 - Check message attribute values don't have invalid characters. ### Version 1.4.14, 2026-07-25 - Make sure all Salt and Nonce errors can be handled with the protocol. - Add note in README on tls-exporter. ### Version 1.4.13, 2026-07-23 - Have a Nonce class for validation. - Have a Salt class for validation. - Handle exceptions from the auth function. - Optional extensions should be ignored. - Check for invalid GS2 header. ### Version 1.4.12, 2026-07-05 - Check for invalid escape chars in username. ### Version 1.4.11, 2026-07-04 - Make sure all errors detected by the server get returned to the client as valid errors. ### Version 1.4.10, 2026-06-27 - Implement max and min guards for iteration count. - The xor() function should fail if the two values are of unequal lengths - Handle the case of an error in first server message - Fix formatting in channel binding error message - SHA3-512 didn't work, but it should do now ### Version 1.4.9, 2026-06-19 - Fix typo in error message. * Strictly validate on base 64 decoding * Handle escaping in usernames according to the spec. * [SASLprep](https://www.rfc-editor.org/info/rfc8265/): Raise exception rather than using assert ### Version 1.4.8, 2026-01-06 - Use the [`secrets`](https://docs.python.org/3/library/secrets.html) module from the Standard Library for generating the salt and nonce. ### Version 1.4.7, 2026-01-04 - The main change in this release is that we now use [`hashlib.pbkdf2_hmac()`](https://docs.python.org/3/library/hashlib.html#hashlib.pbkdf2_hmac) from the standard library, rather than our own Python implementation. Since this is fast, we no longer need to provide a way of using a custom key derivation function, and so `make_stored_server_keys()` has been removed. - To avoid timing attacks we now use [`hmac.compare_digest()`](https://docs.python.org/3/library/hmac.html#hmac.compare_digest) for digest comparisons. ### Version 1.4.6, 2025-07-05 - Drop support for Python 3.8, and add support for 3.13 ### 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.