diff options
| author | Gregor Kleen <gkleen@yggdrasil.li> | 2026-06-06 18:45:28 +0200 |
|---|---|---|
| committer | Gregor Kleen <gkleen@yggdrasil.li> | 2026-06-06 18:45:28 +0200 |
| commit | 5b4f1110443d01a3a0f4b73e01c1b44be7560276 (patch) | |
| tree | f9ed00e713cca00846a3fedeb0bc169ce3170e25 /hosts/surtr/email/password-server/password_server | |
| parent | c26bb533bc50caa873d750ff43a1f4798cf267b3 (diff) | |
| download | nixos-flakes.tar nixos-flakes.tar.gz nixos-flakes.tar.bz2 nixos-flakes.tar.xz nixos-flakes.zip | |
pw.bouncy.emailflakes
Diffstat (limited to 'hosts/surtr/email/password-server/password_server')
| -rw-r--r-- | hosts/surtr/email/password-server/password_server/__init__.py | 0 | ||||
| -rw-r--r-- | hosts/surtr/email/password-server/password_server/__main__.py | 268 |
2 files changed, 268 insertions, 0 deletions
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 --- /dev/null +++ b/hosts/surtr/email/password-server/password_server/__init__.py | |||
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 @@ | |||
| 1 | from systemd.daemon import listen_fds | ||
| 2 | from sdnotify import SystemdNotifier | ||
| 3 | import sys | ||
| 4 | import logging | ||
| 5 | from threading import Thread | ||
| 6 | import uvicorn | ||
| 7 | import argparse | ||
| 8 | from functools import partial | ||
| 9 | from inspect import signature | ||
| 10 | from starlette.applications import Starlette | ||
| 11 | from starlette.routing import Route, Mount | ||
| 12 | from starlette.responses import PlainTextResponse, HTMLResponse | ||
| 13 | from starlette.datastructures import State, Secret | ||
| 14 | from starlette.middleware import Middleware | ||
| 15 | from starlette.middleware.authentication import AuthenticationMiddleware | ||
| 16 | from contextlib import asynccontextmanager | ||
| 17 | from pathlib import Path | ||
| 18 | import os | ||
| 19 | from psycopg_pool import ConnectionPool | ||
| 20 | from psycopg.rows import namedtuple_row | ||
| 21 | from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser, requires | ||
| 22 | from starlette.requests import HTTPConnection | ||
| 23 | from cryptography.hazmat.primitives import hashes | ||
| 24 | from cryptography.hazmat.primitives.hmac import HMAC | ||
| 25 | from cryptography.hazmat.primitives.kdf.hkdf import HKDF | ||
| 26 | from cryptography.hazmat.primitives.asymmetric import ed25519 | ||
| 27 | from cryptography.hazmat.primitives.kdf.argon2 import Argon2id | ||
| 28 | import jwt | ||
| 29 | from datetime import datetime, timezone, timedelta | ||
| 30 | from base64 import b64encode | ||
| 31 | from xkcdpass import xkcd_password as xp | ||
| 32 | from cryptography.hazmat.primitives import constant_time | ||
| 33 | |||
| 34 | class SSLProxyAuthBackend(AuthenticationBackend): | ||
| 35 | def __init__(self, state): | ||
| 36 | self.state = state | ||
| 37 | |||
| 38 | async def authenticate(self, conn): | ||
| 39 | if 'SSL-CLIENT-VERIFY' not in conn.headers or 'SSL-CLIENT-S-DN' not in conn.headers: | ||
| 40 | return | ||
| 41 | |||
| 42 | if conn.headers['SSL-CLIENT-VERIFY'].lower() == 'none': | ||
| 43 | return | ||
| 44 | |||
| 45 | if conn.headers['SSL-CLIENT-VERIFY'].lower() != 'success': | ||
| 46 | raise AuthenticationError('Requires SSL-CLIENT-VERIFY: success') | ||
| 47 | |||
| 48 | username = conn.headers['SSL-CLIENT-S-DN'].removeprefix('CN=') | ||
| 49 | if not username: | ||
| 50 | raise AuthenticationError('Requires SSL-CLIENT-S-DN: CN=(.+)') | ||
| 51 | |||
| 52 | creds = ["authenticated"] | ||
| 53 | |||
| 54 | with self.state.db_pool.connection() as conn: | ||
| 55 | with conn.cursor() as cur: | ||
| 56 | cur.row_factory = namedtuple_row | ||
| 57 | |||
| 58 | 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}) | ||
| 59 | if (row := cur.fetchone()) is not None: | ||
| 60 | if row.exists: | ||
| 61 | creds.append("admin") | ||
| 62 | |||
| 63 | return AuthCredentials(creds), SimpleUser(username) | ||
| 64 | |||
| 65 | @requires("admin") | ||
| 66 | def gen_jwt(request): | ||
| 67 | if 'mailbox' not in request.query_params or not request.query_params['mailbox']: | ||
| 68 | return PlainTextResponse('Require GET parameter mailbox', status_code=400) | ||
| 69 | username = request.query_params['mailbox'] | ||
| 70 | |||
| 71 | private_key = derive_jwk(request.app.state.secret) | ||
| 72 | return PlainTextResponse(jwt.encode( | ||
| 73 | { | ||
| 74 | "exp": datetime.now(tz=timezone.utc) + timedelta(days=2), | ||
| 75 | "sub": username, | ||
| 76 | "pw": current_mailbox_password(db_pool=request.app.state.db_pool, username=username, secret=request.app.state.secret), | ||
| 77 | }, | ||
| 78 | private_key, | ||
| 79 | algorithm='EdDSA', | ||
| 80 | )) | ||
| 81 | |||
| 82 | async def handle_reset(request): | ||
| 83 | if request.method == "GET": | ||
| 84 | if 'jwt' not in request.query_params: | ||
| 85 | return PlainTextResponse('Require GET parameter jwt', status_code=400) | ||
| 86 | provided_jwt = request.query_params['jwt'] | ||
| 87 | else: | ||
| 88 | form = await request.form() | ||
| 89 | if 'jwt' not in form: | ||
| 90 | return PlainTextResponse('Requires POST multipart form parameter jwt', status_code=400) | ||
| 91 | provided_jwt = form['jwt'] | ||
| 92 | |||
| 93 | private_key = derive_jwk(request.app.state.secret) | ||
| 94 | claims = jwt.decode( | ||
| 95 | provided_jwt, | ||
| 96 | private_key.public_key(), | ||
| 97 | algorithms=['EdDSA'], | ||
| 98 | ) | ||
| 99 | if 'sub' not in claims: | ||
| 100 | return PlainTextResponse('Requires sub claim within provided jwt', status_code=400) | ||
| 101 | username = claims['sub'] | ||
| 102 | if 'pw' in claims: | ||
| 103 | password_hash = current_mailbox_password(db_pool=request.app.state.db_pool, username=username, secret=request.app.state.secret) | ||
| 104 | if not constant_time.bytes_eq(password_hash.encode('utf-8'), claims['pw'].encode('utf-8')): | ||
| 105 | return PlainTextResponse('Password was changed since jwt was issued', status_code=403) | ||
| 106 | |||
| 107 | if request.method == "GET": | ||
| 108 | wordfile = xp.locate_wordfile() | ||
| 109 | wordlist = xp.generate_wordlist(wordfile=wordfile) | ||
| 110 | new_password = xp.generate_xkcdpassword(wordlist) | ||
| 111 | new_jwt = jwt.encode( | ||
| 112 | { | ||
| 113 | "exp": datetime.now(tz=timezone.utc) + timedelta(hours=1), | ||
| 114 | "sub": username, | ||
| 115 | "pw": current_mailbox_password(db_pool=request.app.state.db_pool, username=username, secret=request.app.state.secret), | ||
| 116 | "new_pw": new_password, | ||
| 117 | }, | ||
| 118 | private_key, | ||
| 119 | algorithm='EdDSA', | ||
| 120 | ) | ||
| 121 | return HTMLResponse("<form method=\"POST\"><dl><dt>New password</dt><dd style=\"font-family: monospace; white-space: pre-wrap\">{new_password}</dd></dl><input type=\"hidden\" name=\"jwt\" value=\"{jwt}\"><button>Accept new password</button></form>".format(new_password=new_password, jwt=new_jwt)) | ||
| 122 | else: | ||
| 123 | if 'new_pw' not in claims: | ||
| 124 | return PlainTextResponse('Requires new_pw claim within provided jwt', status_code=400) | ||
| 125 | new_password = claims['new_pw'] | ||
| 126 | salt = os.urandom(16) | ||
| 127 | kdf = Argon2id( | ||
| 128 | salt=salt, | ||
| 129 | length=32, | ||
| 130 | iterations=3, | ||
| 131 | lanes=1, | ||
| 132 | memory_cost=64 * 1024, | ||
| 133 | ) | ||
| 134 | pw_hash = kdf.derive_phc_encoded(new_password.encode('utf-8')) | ||
| 135 | with request.app.state.db_pool.connection() as conn: | ||
| 136 | with conn.cursor() as cur: | ||
| 137 | 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'))}) | ||
| 138 | return HTMLResponse("<dl><dt>New password</dt><dd style=\"font-family: monospace; white-space: pre-wrap\">{new_password}</dd></dl>".format(new_password=new_password)) | ||
| 139 | |||
| 140 | |||
| 141 | def current_mailbox_password(*, db_pool, username, secret): | ||
| 142 | with db_pool.connection() as conn: | ||
| 143 | with conn.cursor() as cur: | ||
| 144 | cur.row_factory = namedtuple_row | ||
| 145 | |||
| 146 | cur.execute('SELECT password FROM "mailbox" WHERE "mailbox" = %(user)s', params = {'user': username}) | ||
| 147 | row = cur.fetchone() | ||
| 148 | hmac = HMAC(derive_key(secret, b'hmac'), hashes.BLAKE2b(64)) | ||
| 149 | hmac.update((row.password if row.password else '').encode('utf-8') if row else b'') | ||
| 150 | return b64encode(hmac.finalize()).decode('utf-8') | ||
| 151 | |||
| 152 | def derive_key(secret, info, length=32): | ||
| 153 | hkdf = HKDF( | ||
| 154 | algorithm=hashes.BLAKE2b(64), | ||
| 155 | length=length, | ||
| 156 | salt=None, | ||
| 157 | info=info, | ||
| 158 | ) | ||
| 159 | return hkdf.derive(str(secret).encode('utf-8')) | ||
| 160 | |||
| 161 | def derive_jwk(secret): | ||
| 162 | return ed25519.Ed25519PrivateKey.from_private_bytes(derive_key(secret, b'jwk', length=32)) | ||
| 163 | |||
| 164 | def make_app(): | ||
| 165 | state = State() | ||
| 166 | secret_path = Path(os.environ["CREDENTIALS_DIRECTORY"]) / 'secret' | ||
| 167 | with secret_path.open('r') as fh: | ||
| 168 | state.secret = Secret(fh.read().strip()) | ||
| 169 | state.db_pool = ConnectionPool(min_size=1) | ||
| 170 | state.db_pool.wait() | ||
| 171 | |||
| 172 | @asynccontextmanager | ||
| 173 | async def lifespan(app): | ||
| 174 | app.state = state | ||
| 175 | yield | ||
| 176 | |||
| 177 | return Starlette( | ||
| 178 | routes=[ | ||
| 179 | Route('/', handle_reset, methods=["GET", "POST"]), | ||
| 180 | Mount('/jwt', | ||
| 181 | routes=[ | ||
| 182 | Route('/', gen_jwt), | ||
| 183 | ], | ||
| 184 | middleware=[Middleware(AuthenticationMiddleware, backend=SSLProxyAuthBackend(state=state))], | ||
| 185 | ), | ||
| 186 | ], | ||
| 187 | lifespan=lifespan, | ||
| 188 | ) | ||
| 189 | |||
| 190 | def serve(): | ||
| 191 | fds = listen_fds() | ||
| 192 | if fds: | ||
| 193 | app = make_app() | ||
| 194 | for fd in fds: | ||
| 195 | Thread(name=f'Server for fd {fd}', target=partial(uvicorn.run, app=app, fd=fd)).start() | ||
| 196 | else: | ||
| 197 | return 2 | ||
| 198 | |||
| 199 | SystemdNotifier().notify('READY=1') | ||
| 200 | |||
| 201 | def main(): | ||
| 202 | global logger | ||
| 203 | logger = logging.getLogger('uvicorn') | ||
| 204 | console_handler = logging.StreamHandler() | ||
| 205 | console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') ) | ||
| 206 | if sys.stderr.isatty(): | ||
| 207 | console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') ) | ||
| 208 | logger.addHandler(console_handler) | ||
| 209 | logger.setLevel(logging.DEBUG) | ||
| 210 | |||
| 211 | # log uncaught exceptions | ||
| 212 | def log_exceptions(type, value, tb): | ||
| 213 | global logger | ||
| 214 | |||
| 215 | logger.error(value) | ||
| 216 | sys.__excepthook__(type, value, tb) # calls default excepthook | ||
| 217 | |||
| 218 | sys.excepthook = log_exceptions | ||
| 219 | |||
| 220 | def set_default_subparser(self, name, args=None, positional_args=0): | ||
| 221 | """default subparser selection. Call after setup, just before parse_args() | ||
| 222 | name: is the name of the subparser to call by default | ||
| 223 | args: if set is the argument list handed to parse_args() | ||
| 224 | |||
| 225 | , tested with 2.7, 3.2, 3.3, 3.4 | ||
| 226 | it works with 2.6 assuming argparse is installed | ||
| 227 | """ | ||
| 228 | subparser_found = False | ||
| 229 | for arg in sys.argv[1:]: | ||
| 230 | if arg in ['-h', '--help']: # global help if no subparser | ||
| 231 | break | ||
| 232 | else: | ||
| 233 | for x in self._subparsers._actions: | ||
| 234 | if not isinstance(x, argparse._SubParsersAction): | ||
| 235 | continue | ||
| 236 | for sp_name in x._name_parser_map.keys(): | ||
| 237 | if sp_name in sys.argv[1:]: | ||
| 238 | subparser_found = True | ||
| 239 | if not subparser_found: | ||
| 240 | # insert default in last position before global positional | ||
| 241 | # arguments, this implies no global options are specified after | ||
| 242 | # first positional argument | ||
| 243 | if args is None: | ||
| 244 | sys.argv.insert(len(sys.argv) - positional_args, name) | ||
| 245 | else: | ||
| 246 | args.insert(len(args) - positional_args, name) | ||
| 247 | |||
| 248 | argparse.ArgumentParser.set_default_subparser = set_default_subparser | ||
| 249 | |||
| 250 | parser = argparse.ArgumentParser( | ||
| 251 | prog=__name__, | ||
| 252 | ) | ||
| 253 | subparsers = parser.add_subparsers() | ||
| 254 | serve_parser = subparsers.add_parser('serve') | ||
| 255 | serve_parser.set_defaults(cmd = serve) | ||
| 256 | parser.set_default_subparser('serve') | ||
| 257 | args = parser.parse_args() | ||
| 258 | |||
| 259 | return args.cmd( | ||
| 260 | **{ | ||
| 261 | k: v | ||
| 262 | for k, v in vars(args).items() | ||
| 263 | if k in signature(args.cmd).parameters.keys() | ||
| 264 | } | ||
| 265 | ) | ||
| 266 | |||
| 267 | if __name__ == '__main__': | ||
| 268 | sys.exit(main()) | ||
