#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""Secure Remote Password (SRP-6a) primitives for web authentication.SRP lets the server authenticate a user's password **without ever storing orreceiving the password itself**. At registration the client turns its`(email, password)` into a *verifier* `v = g**x mod N` (where the privateexponent `x` is derived from a random salt and the password) and the serverkeeps only the salt and `v`. Recovering the password from `v` is a discrete-logproblem, so the stored material is safe at rest.Authentication is a zero-knowledge handshake (RFC 5054 / SRP-6a): client server a, A = g**a ──A──▶ ◀─salt, B── b, B = k*v + g**b u = H(A,B) u = H(A,B) S = (B - k*g**x)**(a+u*x) S = (A * v**u)**b K = H(S) K = H(S) M1 = H(...,K) ──M1──▶ verify M1, derive M2 ◀──M2── M2 = H(A, M1, K) verify M2Both endpoints arrive at the same shared key `K` only when the password used bythe client matches the verifier held by the server; neither `a`, `b`, `x`, northe password ever crosses the wire. This module implements both ends(:class:`SrpClient`, :class:`SrpServer`) plus :func:`verify_password`, which runsthe whole handshake locally — the path used when a password *is* availableserver-side (e.g. a local test harness) and must be checkedagainst a stored verifier.This is **web-authentication** material only: it lives under `euler_site` andshares no code or key material with `solver.crypto` (the git-filter solutionencryption). Logging in gates web access; it does not unlock the encryptionmaster key.The group is the RFC 5054 2048-bit safe prime with generator `g = 2` andSHA-256 throughout; :data:`VERSION` records this so the parameters can evolvelater. Integer values fed to the hash (`A`, `B`, `S`) are left-padded tothe byte length of `N` (`PAD`), which a browser-side client must matchbyte-for-byte to interoperate.**The password hash in `x` is Argon2id**, `x = H(salt | Argon2id(email ":" password,"euler-srp-x:" | salt))` — not RFC 5054's `H(salt | H(email ":" password))`, which is fast enoughthat a stolen verifier could be guessed at GPU speed. Its parameters (:data:`KDF`) are stored peraccount and named in each challenge, so the cost can rise later without stranding anybody. TheArgon2 salt carries a label so this derivation never coincides with the vault's key from the samepassword and salt (`euler-vault-pk:`). The browser does the work (`hash-wasm`); the service neverderives `x` for a sign-in, only here, for :func:`verify_password` and the tests."""from__future__importannotations__all__=['SrpClient','SrpServer','SrpToken','make_srp_token','compute_verifier','verify_password','decoy_token','VERSION','KDF']importhmacimportsecretsfromhashlibimportsha256fromtypingimportNamedTuple# RFC 5054, Appendix A — 2048-bit group ("N") and generator ("g")._N:int=int('AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050''A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50''E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8''55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B''CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748''544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6''AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6''94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73',16)_g:int=2_N_BYTES:int=(_N.bit_length()+7)//8#: Version tag stamped into every token; encodes the hash + group so they can change later.VERSION:str='srp6a-sha256-2048'#: Random salt length in bytes used when minting a fresh verifier._SALT_BYTES:int=16#: The password hash inside `x` for a new verifier: Argon2id, 64 MiB, 3 passes, 1 lane. The#: string is stored per account and sent with each challenge, so the browser derives `x` the#: way that account's verifier was made; `srp.js` carries the same constant.KDF:str='argon2id$m=65536,t=3,p=1'#: Prefixed to the salt for Argon2, so `x` never shares a derivation with the vault key._ARGON2_LABEL:bytes=b'euler-srp-x:'def_hash(*parts:bytes)->bytes:"""Return the SHA-256 digest of the concatenated byte chunks."""digest=sha256()forpartinparts:digest.update(part)returndigest.digest()def_hash_int(*parts:bytes)->int:"""Return the SHA-256 digest of the concatenated chunks as a big-endian integer."""returnint.from_bytes(_hash(*parts),'big')def_pad(value:int)->bytes:"""Left-pad an integer to the byte length of `N` (the SRP `PAD` operation)."""returnvalue.to_bytes(_N_BYTES,'big')# Multiplier parameter k = H(N | PAD(g)); fixed by the group, computed once._k:int=_hash_int(_pad(_N),_pad(_g))def_random_exponent()->int:"""Return a fresh private exponent in `[1, N)` for an ephemeral key."""return1+secrets.randbelow(_N-1)def_argon2_params(kdf:str)->tuple[int,int,int]:"""`(memory KiB, passes, lanes)` from an `argon2id$m=…,t=…,p=…` string."""name,_,params=kdf.partition('$')values=dict(pair.split('=',1)forpairinparams.split(','))ifname=='argon2id'else{}try:returnint(values['m']),int(values['t']),int(values['p'])except(KeyError,ValueError)asexc:raiseValueError(f'unsupported srp kdf: {kdf!r}')fromexcdef_derive_x(salt:bytes,email:str,password:str,kdf:str=KDF)->int:"""Derive the private exponent `x = H(salt | Argon2id(email ":" password, "euler-srp-x:" | salt))`, with the Argon2id parameters the account's *kdf* names. Raises: ValueError: If *kdf* is not an `argon2id$m=…,t=…,p=…` string. """secret=f'{email}:{password}'.encode()fromargon2.low_levelimportType,hash_secret_raw# only local derivations need itmemory,passes,lanes=_argon2_params(kdf)inner=hash_secret_raw(secret,_ARGON2_LABEL+salt,passes,memory,lanes,32,Type.ID)return_hash_int(salt,inner)def_compute_u(client_public:int,server_public:int)->int:"""Compute the scrambling parameter `u = H(PAD(A) | PAD(B))`, rejecting `u == 0`."""u:int=_hash_int(_pad(client_public),_pad(server_public))ifu==0:raiseValueError('SRP handshake failed: u must not be zero')returnudef_session_key(shared:int)->bytes:"""Derive the shared session key `K = H(PAD(S))` from the premaster secret `S`."""return_hash(_pad(shared))def_client_proof(email:str,salt:bytes,client_public:int,server_public:int,session_key:bytes)->bytes:"""Compute the client proof `M1 = H(H(N) XOR H(g) | H(email) | salt | PAD(A) | PAD(B) | K)`."""h_n:int=_hash_int(_pad(_N))h_g:int=_hash_int(_pad(_g))prefix:bytes=(h_n^h_g).to_bytes(sha256().digest_size,'big')return_hash(prefix,_hash(email.encode()),salt,_pad(client_public),_pad(server_public),session_key)def_server_proof(client_public:int,client_proof:bytes,session_key:bytes)->bytes:"""Compute the server proof `M2 = H(PAD(A) | M1 | K)`."""return_hash(_pad(client_public),client_proof,session_key)defcompute_verifier(salt:bytes,email:str,password:str,kdf:str=KDF)->int:"""Compute the SRP verifier `v = g**x mod N` for the given salt and credentials."""returnpow(_g,_derive_x(salt,email,password,kdf),_N)classSrpToken(NamedTuple):"""The server's stored secret for one user: the salt and password verifier. Serialised to `<version>$<salt-hex>$<verifier-hex>`; the on-disk user store keeps the salt and verifier as separate hex fields but round-trips through this type. """salt:bytesverifier:int#: The Argon2id parameters `x` was derived with for this verifier (:data:`KDF` when new).kdf:str=KDF@classmethoddefcreate(cls,email:str,password:str,*,salt:bytes|None=None)->SrpToken:"""Mint a token from credentials, generating a random salt when none is supplied."""salt=saltifsaltisnotNoneelsesecrets.token_bytes(_SALT_BYTES)returncls(salt=salt,verifier=compute_verifier(salt,email,password))@classmethoddefparse(cls,token:str)->SrpToken:"""Parse a serialised token, raising ValueError if it is malformed or a foreign version. The fourth field (which itself contains `$`) names the KDF. """parts=token.split('$',3)iflen(parts)!=4:raiseValueError('malformed srp token')version,salt_hex,verifier_hex,kdf=partsifversion!=VERSION:raiseValueError(f'unsupported srp token version: {version!r}')_argon2_params(kdf)# raises for anything but argon2id parametersreturncls(salt=bytes.fromhex(salt_hex),verifier=int(verifier_hex,16),kdf=kdf)defserialize(self)->str:"""Serialise to the `<version>$<salt-hex>$<verifier-hex>$<kdf>` storage form."""returnf'{VERSION}${self.salt.hex()}${self.verifier:x}${self.kdf}'def__str__(self)->str:"""Return a truncated, non-secret-leaking summary."""returnf'{self.__class__.__name__}(salt={self.salt.hex()}, verifier={self.verifier:x}'[:48]+'...)'defmake_srp_token(email:str,password:str,*,salt:bytes|None=None)->str:"""Mint and serialise an SRP token for `(email, password)` — the storage entry point."""returnSrpToken.create(email,password,salt=salt).serialize()defdecoy_token(email:str,secret:bytes)->SrpToken:"""Return a deterministic dummy token for a nonexistent user. Lets the challenge step answer for unknown emails with a *stable* salt and a plausible verifier, so a caller cannot distinguish a real account from an unknown one by watching the salt (the subsequent proof still fails for any password). Derived from a per-process `secret` and the email via HMAC-SHA256, so it is consistent within a run yet reveals nothing about real users. It names the current :data:`KDF`, as a real account's challenge does. """salt:bytes=hmac.new(secret,b'srp-decoy-salt:'+email.encode(),sha256).digest()[:_SALT_BYTES]x:int=_hash_int(hmac.new(secret,b'srp-decoy-x:'+email.encode(),sha256).digest())returnSrpToken(salt=salt,verifier=pow(_g,x,_N))classSrpClient:"""The client side of an SRP-6a handshake: knows the password, proves it to the server."""def__init__(self,email:str,password:str,kdf:str=KDF)->None:self.email:str=emailself._password:str=passwordself._kdf:str=kdfself._a:int=_random_exponent()#: The client's ephemeral public value `A = g**a mod N` (sent to the server).self.public:int=pow(_g,self._a,_N)self._session_key:bytes|None=Noneself._proof:bytes|None=Nonedefprocess_challenge(self,salt:bytes,server_public:int)->bytes:"""Consume the server's `salt` and public `B`, returning the client proof `M1`. Raises: ValueError: If `B` is invalid (`B mod N == 0`). """ifserver_public%_N==0:raiseValueError('SRP handshake failed: server public value B is invalid')x:int=_derive_x(salt,self.email,self._password,self._kdf)u:int=_compute_u(self.public,server_public)# S = (B - k * g**x) ** (a + u * x) mod Nshared:int=pow(server_public-_k*pow(_g,x,_N),self._a+u*x,_N)self._session_key=_session_key(shared)self._proof=_client_proof(self.email,salt,self.public,server_public,self.session_key)returnself.proofdefverify_session(self,server_proof:bytes)->bool:"""Return True if the server's proof `M2` matches, confirming a shared session key."""ifself._session_keyisNoneorself._proofisNone:raiseValueError('process_challenge must be called before verify_session')expected:bytes=_server_proof(self.public,self._proof,self._session_key)returnsecrets.compare_digest(expected,server_proof)@propertydefsession_key(self)->bytes:"""The negotiated shared key `K` (available only after :meth:`process_challenge`)."""ifself._session_keyisNone:raiseValueError('process_challenge must be called before the session key is available')returnself._session_key@propertydefproof(self)->bytes:"""The client proof `M1` (available only after :meth:`process_challenge`)."""ifself._proofisNone:raiseValueError('process_challenge must be called before the proof is available')returnself._proofclassSrpServer:"""The server side of an SRP-6a handshake: holds the verifier, validates the client proof."""def__init__(self,email:str,token:SrpToken)->None:self.email:str=emailself._token:SrpToken=tokenself._b:int=_random_exponent()# B = (k * v + g**b) mod N#: The server's ephemeral public value `B` (sent to the client with the salt).self.public:int=(_k*token.verifier+pow(_g,self._b,_N))%_Nself._session_key:bytes|None=None@propertydefsalt(self)->bytes:"""The stored salt to send to the client alongside `B`."""returnself._token.saltdefverify(self,client_public:int,client_proof:bytes)->bytes:"""Validate the client's public `A` and proof `M1`; return the server proof `M2`. Raises: ValueError: If `A` is invalid (`A mod N == 0`) or the proof does not match (wrong password). """ifclient_public%_N==0:raiseValueError('SRP handshake failed: client public value A is invalid')u:int=_compute_u(client_public,self.public)# S = (A * v**u) ** b mod Nshared:int=pow(client_public*pow(self._token.verifier,u,_N),self._b,_N)self._session_key=_session_key(shared)expected:bytes=_client_proof(self.email,self._token.salt,client_public,self.public,self.session_key)ifnotsecrets.compare_digest(expected,client_proof):raiseValueError('SRP handshake failed: bad client proof (wrong password)')return_server_proof(client_public,client_proof,self.session_key)@propertydefsession_key(self)->bytes:"""The negotiated shared key `K` (available only after a successful :meth:`verify`)."""ifself._session_keyisNone:raiseValueError('verify must succeed before the session key is available')returnself._session_keydefverify_password(email:str,password:str,token:SrpToken)->bool:"""Return True if `password` matches the verifier in `token` for `email`. Runs a complete SRP-6a handshake locally (client with the supplied password, server with the stored verifier), exercising exactly the same code paths a remote, zero-knowledge client would drive. Any handshake failure (i.e. a wrong password) yields False rather than raising. """server:SrpServer=SrpServer(email,token)client:SrpClient=SrpClient(email,password,token.kdf)try:client_proof:bytes=client.process_challenge(server.salt,server.public)server.verify(client.public,client_proof)exceptValueError:returnFalsereturnTrue