#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""User store: the SRP verifier database at `<state>/users.json`.One entry per registered user, keyed by normalised email, recording only theSRP `{salt, verifier}` (never a password or password-equivalent) plus theauthorization profile, the recorded Terms acceptance, and bookkeeping.A user record exists only once registration **completes** — an outstandinginvite lives in :mod:`euler_site.auth.pending`, not here.This is **web-auth** material, deliberately separate from `solver.crypto`(the git-filter solution encryption): it gates web access and shares no keymaterial with the encryption master key.The file is JSON, written atomically at mode `0600`:: { "version": "srp6a-sha256-2048", "users": { "user@example.com": { "salt": "<hex>", "verifier": "<hex>", "kdf": "argon2id$m=65536,t=3,p=1", "terms_version": "1", "terms_accepted_at": "<iso8601>", "created": "<iso8601>", "disabled": false } } }"""from__future__importannotations__all__=['UserRecord','UserStore','normalize_email']fromdatetimeimportdatetime,timezonefrompathlibimportPathfromtypingimportAny,NamedTuplefromeuler_site.auth.srpimportKDF,VERSION,SrpTokenfromeuler_site.auth.storageimportload_json,save_jsondefnormalize_email(email:str)->str:"""Return the canonical store key for an email (trimmed, lowercased)."""returnemail.strip().lower()def_now()->str:"""Current UTC time in ISO-8601, the store's timestamp format."""returndatetime.now(timezone.utc).isoformat(timespec='seconds')classUserRecord(NamedTuple):"""One registered user, as stored (secrets are the SRP salt/verifier only)."""email:strsalt:str# hexverifier:str# hexterms_version:strterms_accepted_at:strcreated:strdisabled:bool#: The Argon2id parameters the verifier's `x` was derived with (`srp.KDF` when it was made).kdf:str=KDF@propertydefsrp_token(self)->SrpToken:"""The stored SRP material as the handshake type."""returnSrpToken(salt=bytes.fromhex(self.salt),verifier=int(self.verifier,16),kdf=self.kdf)defsummary(self)->dict[str,Any]:"""A secret-free view for listings (no salt/verifier). The **profile** is not here — it lives in `authorizations.json`, joined in by the caller."""return{'email':self.email,'created':self.created,'disabled':self.disabled,'terms_version':self.terms_version}classUserStore:"""The verifier DB: single-writer (the auth service), read per operation."""def__init__(self,path:Path)->None:self._path=pathdef_load(self)->dict[str,dict[str,Any]]:users=load_json(self._path).get('users')returnusersifisinstance(users,dict)else{}def_save(self,users:dict[str,dict[str,Any]])->None:save_json(self._path,{'version':VERSION,'users':users})defget(self,email:str)->UserRecord|None:"""Return the record for *email*, or None."""raw=self._load().get(normalize_email(email))ifrawisNone:returnNonereturnUserRecord(email=normalize_email(email),salt=str(raw.get('salt','')),verifier=str(raw.get('verifier','')),terms_version=str(raw.get('terms_version','')),terms_accepted_at=str(raw.get('terms_accepted_at','')),created=str(raw.get('created','')),disabled=bool(raw.get('disabled',True)),kdf=str(raw.get('kdf',KDF)))defcreate(self,email:str,salt:str,verifier:str,terms_version:str,terms_accepted_at:str,*,kdf:str=KDF)->UserRecord:"""Create the record at registration completion (the set-password step); enabled. The **profile is not stored here** — it lives in `authorizations.json`."""users=self._load()key=normalize_email(email)users[key]={'salt':salt,'verifier':verifier,'kdf':kdf,'terms_version':terms_version,'terms_accepted_at':terms_accepted_at,'created':_now(),'disabled':False}self._save(users)record=self.get(key)assertrecordisnotNonereturnrecorddefset_credentials(self,email:str,salt:str,verifier:str,*,kdf:str=KDF)->bool:"""Replace the SRP material (a reset or a change); False if the user is unknown."""users=self._load()key=normalize_email(email)ifkeynotinusers:returnFalseusers[key]['salt']=saltusers[key]['verifier']=verifierusers[key]['kdf']=kdfself._save(users)returnTruedefset_enabled(self,email:str,enabled:bool)->bool:"""Enable/disable the account; False if the user is unknown."""users=self._load()key=normalize_email(email)ifkeynotinusers:returnFalseusers[key]['disabled']=notenabledself._save(users)returnTruedefremove(self,email:str)->bool:"""Delete the account outright; False if the user is unknown."""users=self._load()ifusers.pop(normalize_email(email),None)isNone:returnFalseself._save(users)returnTruedefall(self)->list[UserRecord]:"""Every record, sorted by email."""records=(self.get(email)foremailinsorted(self._load()))return[recordforrecordinrecordsifrecordisnotNone]