From 5b4f1110443d01a3a0f4b73e01c1b44be7560276 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Sat, 6 Jun 2026 18:45:28 +0200 Subject: pw.bouncy.email --- .../password-server/password_server/__init__.py | 0 .../password-server/password_server/__main__.py | 268 +++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 hosts/surtr/email/password-server/password_server/__init__.py create mode 100644 hosts/surtr/email/password-server/password_server/__main__.py (limited to 'hosts/surtr/email/password-server/password_server') diff --git a/hosts/surtr/email/password-server/password_server/__init__.py b/hosts/surtr/email/password-server/password_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hosts/surtr/email/password-server/password_server/__main__.py b/hosts/surtr/email/password-server/password_server/__main__.py new file mode 100644 index 00000000..f2321080 --- /dev/null +++ b/hosts/surtr/email/password-server/password_server/__main__.py @@ -0,0 +1,268 @@ +from systemd.daemon import listen_fds +from sdnotify import SystemdNotifier +import sys +import logging +from threading import Thread +import uvicorn +import argparse +from functools import partial +from inspect import signature +from starlette.applications import Starlette +from starlette.routing import Route, Mount +from starlette.responses import PlainTextResponse, HTMLResponse +from starlette.datastructures import State, Secret +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from contextlib import asynccontextmanager +from pathlib import Path +import os +from psycopg_pool import ConnectionPool +from psycopg.rows import namedtuple_row +from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser, requires +from starlette.requests import HTTPConnection +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.hmac import HMAC +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id +import jwt +from datetime import datetime, timezone, timedelta +from base64 import b64encode +from xkcdpass import xkcd_password as xp +from cryptography.hazmat.primitives import constant_time + +class SSLProxyAuthBackend(AuthenticationBackend): + def __init__(self, state): + self.state = state + + async def authenticate(self, conn): + if 'SSL-CLIENT-VERIFY' not in conn.headers or 'SSL-CLIENT-S-DN' not in conn.headers: + return + + if conn.headers['SSL-CLIENT-VERIFY'].lower() == 'none': + return + + if conn.headers['SSL-CLIENT-VERIFY'].lower() != 'success': + raise AuthenticationError('Requires SSL-CLIENT-VERIFY: success') + + username = conn.headers['SSL-CLIENT-S-DN'].removeprefix('CN=') + if not username: + raise AuthenticationError('Requires SSL-CLIENT-S-DN: CN=(.+)') + + creds = ["authenticated"] + + with self.state.db_pool.connection() as conn: + with conn.cursor() as cur: + cur.row_factory = namedtuple_row + + cur.execute('SELECT EXISTS(SELECT true from "mailbox" INNER JOIN "password_admin" ON "mailbox".id = "password_admin"."mailbox" where "mailbox"."mailbox" = %(user)s) as "exists"', params = {'user': username}) + if (row := cur.fetchone()) is not None: + if row.exists: + creds.append("admin") + + return AuthCredentials(creds), SimpleUser(username) + +@requires("admin") +def gen_jwt(request): + if 'mailbox' not in request.query_params or not request.query_params['mailbox']: + return PlainTextResponse('Require GET parameter mailbox', status_code=400) + username = request.query_params['mailbox'] + + private_key = derive_jwk(request.app.state.secret) + return PlainTextResponse(jwt.encode( + { + "exp": datetime.now(tz=timezone.utc) + timedelta(days=2), + "sub": username, + "pw": current_mailbox_password(db_pool=request.app.state.db_pool, username=username, secret=request.app.state.secret), + }, + private_key, + algorithm='EdDSA', + )) + +async def handle_reset(request): + if request.method == "GET": + if 'jwt' not in request.query_params: + return PlainTextResponse('Require GET parameter jwt', status_code=400) + provided_jwt = request.query_params['jwt'] + else: + form = await request.form() + if 'jwt' not in form: + return PlainTextResponse('Requires POST multipart form parameter jwt', status_code=400) + provided_jwt = form['jwt'] + + private_key = derive_jwk(request.app.state.secret) + claims = jwt.decode( + provided_jwt, + private_key.public_key(), + algorithms=['EdDSA'], + ) + if 'sub' not in claims: + return PlainTextResponse('Requires sub claim within provided jwt', status_code=400) + username = claims['sub'] + if 'pw' in claims: + password_hash = current_mailbox_password(db_pool=request.app.state.db_pool, username=username, secret=request.app.state.secret) + if not constant_time.bytes_eq(password_hash.encode('utf-8'), claims['pw'].encode('utf-8')): + return PlainTextResponse('Password was changed since jwt was issued', status_code=403) + + if request.method == "GET": + wordfile = xp.locate_wordfile() + wordlist = xp.generate_wordlist(wordfile=wordfile) + new_password = xp.generate_xkcdpassword(wordlist) + new_jwt = jwt.encode( + { + "exp": datetime.now(tz=timezone.utc) + timedelta(hours=1), + "sub": username, + "pw": current_mailbox_password(db_pool=request.app.state.db_pool, username=username, secret=request.app.state.secret), + "new_pw": new_password, + }, + private_key, + algorithm='EdDSA', + ) + return HTMLResponse("
New password
{new_password}
".format(new_password=new_password, jwt=new_jwt)) + else: + if 'new_pw' not in claims: + return PlainTextResponse('Requires new_pw claim within provided jwt', status_code=400) + new_password = claims['new_pw'] + salt = os.urandom(16) + kdf = Argon2id( + salt=salt, + length=32, + iterations=3, + lanes=1, + memory_cost=64 * 1024, + ) + pw_hash = kdf.derive_phc_encoded(new_password.encode('utf-8')) + with request.app.state.db_pool.connection() as conn: + with conn.cursor() as cur: + cur.execute('UPDATE mailbox SET "password" = %(pw_hash)s WHERE "mailbox" = %(user)s', params = {'user': username, 'pw_hash': "{{ARGON2ID.B64}}{}".format(b64encode(pw_hash.encode('utf-8')).decode('utf-8'))}) + return HTMLResponse("
New password
{new_password}
".format(new_password=new_password)) + + +def current_mailbox_password(*, db_pool, username, secret): + with db_pool.connection() as conn: + with conn.cursor() as cur: + cur.row_factory = namedtuple_row + + cur.execute('SELECT password FROM "mailbox" WHERE "mailbox" = %(user)s', params = {'user': username}) + row = cur.fetchone() + hmac = HMAC(derive_key(secret, b'hmac'), hashes.BLAKE2b(64)) + hmac.update((row.password if row.password else '').encode('utf-8') if row else b'') + return b64encode(hmac.finalize()).decode('utf-8') + +def derive_key(secret, info, length=32): + hkdf = HKDF( + algorithm=hashes.BLAKE2b(64), + length=length, + salt=None, + info=info, + ) + return hkdf.derive(str(secret).encode('utf-8')) + +def derive_jwk(secret): + return ed25519.Ed25519PrivateKey.from_private_bytes(derive_key(secret, b'jwk', length=32)) + +def make_app(): + state = State() + secret_path = Path(os.environ["CREDENTIALS_DIRECTORY"]) / 'secret' + with secret_path.open('r') as fh: + state.secret = Secret(fh.read().strip()) + state.db_pool = ConnectionPool(min_size=1) + state.db_pool.wait() + + @asynccontextmanager + async def lifespan(app): + app.state = state + yield + + return Starlette( + routes=[ + Route('/', handle_reset, methods=["GET", "POST"]), + Mount('/jwt', + routes=[ + Route('/', gen_jwt), + ], + middleware=[Middleware(AuthenticationMiddleware, backend=SSLProxyAuthBackend(state=state))], + ), + ], + lifespan=lifespan, + ) + +def serve(): + fds = listen_fds() + if fds: + app = make_app() + for fd in fds: + Thread(name=f'Server for fd {fd}', target=partial(uvicorn.run, app=app, fd=fd)).start() + else: + return 2 + + SystemdNotifier().notify('READY=1') + +def main(): + global logger + logger = logging.getLogger('uvicorn') + console_handler = logging.StreamHandler() + console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') ) + if sys.stderr.isatty(): + console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') ) + logger.addHandler(console_handler) + logger.setLevel(logging.DEBUG) + + # log uncaught exceptions + def log_exceptions(type, value, tb): + global logger + + logger.error(value) + sys.__excepthook__(type, value, tb) # calls default excepthook + + sys.excepthook = log_exceptions + + def set_default_subparser(self, name, args=None, positional_args=0): + """default subparser selection. Call after setup, just before parse_args() + name: is the name of the subparser to call by default + args: if set is the argument list handed to parse_args() + + , tested with 2.7, 3.2, 3.3, 3.4 + it works with 2.6 assuming argparse is installed + """ + subparser_found = False + for arg in sys.argv[1:]: + if arg in ['-h', '--help']: # global help if no subparser + break + else: + for x in self._subparsers._actions: + if not isinstance(x, argparse._SubParsersAction): + continue + for sp_name in x._name_parser_map.keys(): + if sp_name in sys.argv[1:]: + subparser_found = True + if not subparser_found: + # insert default in last position before global positional + # arguments, this implies no global options are specified after + # first positional argument + if args is None: + sys.argv.insert(len(sys.argv) - positional_args, name) + else: + args.insert(len(args) - positional_args, name) + + argparse.ArgumentParser.set_default_subparser = set_default_subparser + + parser = argparse.ArgumentParser( + prog=__name__, + ) + subparsers = parser.add_subparsers() + serve_parser = subparsers.add_parser('serve') + serve_parser.set_defaults(cmd = serve) + parser.set_default_subparser('serve') + args = parser.parse_args() + + return args.cmd( + **{ + k: v + for k, v in vars(args).items() + if k in signature(args.cmd).parameters.keys() + } + ) + +if __name__ == '__main__': + sys.exit(main()) -- cgit v1.2.3