from __future__ import annotations import hmac import re import secrets from functools import wraps from hashlib import sha256 from pathlib import Path, PurePath from time import time from unicodedata import normalize import argon2 import msgspec _argon = argon2.PasswordHasher() _droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$') class Config(msgspec.Struct): path: Path = Path.cwd() secret: str = secrets.token_hex(12) public: bool = False users: dict[str, User] = {} sessions: dict[str, Session] = {} links: dict[str, Link] = {} class User(msgspec.Struct, omit_defaults=True): privileged: bool = False hash: str = "" lastSeen: int = 0 def set_password(self, password: str): self.hash = _argon.hash(_pwnorm(password)) class Session(msgspec.Struct): username: str lastSeen: int class Link(msgspec.Struct, omit_defaults=True): location: str creator: str = "" expires: int = 0 config = Config() def derived_secret(*params, len=8) -> bytes: """Used to derive secret keys from the main secret""" # Each part is made the same length by hashing first combined = b"".join( sha256( p if isinstance(p, bytes) else f"{p}".encode() ).digest() for p in [config.secret, *params] ) # Output a bytes of the desired length return sha256(combined).digest()[:len] def _pwnorm(password): return normalize('NFC', password).strip().encode() def login(username: str, password: str): un = _pwnorm(username) pw = _pwnorm(password) try: u = config.users[un.decode()] except KeyError: raise ValueError("Invalid username") # Verify password if not u.hash: raise ValueError("Account disabled") if (m := _droppyhash.match(u.hash)) is not None: h, s = m.groups() h2 = hmac.digest(pw + s.encode() + un, b"", "sha256").hex() if not hmac.compare_digest(h, h2): raise ValueError("Invalid password") # Droppy hashes are weak, do a hash update u.set_password(password) else: try: _argon.verify(u.hash, pw) except Exception: raise ValueError("Invalid password") if _argon.check_needs_rehash(u.hash): u.set_password(password) # Login successful now = int(time()) u.lastSeen = now sid = secrets.token_urlsafe(12) config.sessions[sid] = Session(username, now) return u, sid def enc_hook(obj): if isinstance(obj, PurePath): return obj.as_posix() raise TypeError def dec_hook(typ, obj): if typ is Path: return Path(obj) raise TypeError conffile = Path.cwd() / ".cista.toml" def config_update(modify): global config tmpname = conffile.with_suffix(".tmp") try: f = tmpname.open("xb") except FileExistsError: if tmpname.stat().st_mtime < time() - 1: tmpname.unlink() return "collision" try: # Load, modify and save with atomic replace try: old = conffile.read_bytes() c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook) except FileNotFoundError: old = b"" c = Config() # Initialize with defaults c = modify(c) new = msgspec.toml.encode(c, enc_hook=enc_hook) if old == new: f.close() tmpname.unlink() config = c return "read" f.write(new) f.close() tmpname.rename(conffile) # Atomic replace except: f.close() tmpname.unlink() raise config = c return "modified" if old else "created" def modifies_config(modify): """Decorator for functions that modify the config file""" @wraps(modify) def wrapper(*args, **kwargs): m = lambda c: modify(c, *args, **kwargs) # Retry modification in case of write collision while (c := config_update(m)) == "collision": time.sleep(0.01) return c return wrapper @modifies_config def droppy_import(config: Config) -> Config: p = Path.home() / ".droppy/config" cf = msgspec.json.decode((p / "config.json").read_bytes()) db = msgspec.json.decode((p / "db.json").read_bytes()) return msgspec.convert(cf | db, Config) # Load/initialize config file print(conffile, config_update(lambda c: c))