summaryrefslogtreecommitdiff
path: root/hosts/surtr/email/password-server/password_server
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2026-06-06 18:45:28 +0200
committerGregor Kleen <gkleen@yggdrasil.li>2026-06-06 18:45:28 +0200
commit5b4f1110443d01a3a0f4b73e01c1b44be7560276 (patch)
treef9ed00e713cca00846a3fedeb0bc169ce3170e25 /hosts/surtr/email/password-server/password_server
parentc26bb533bc50caa873d750ff43a1f4798cf267b3 (diff)
downloadnixos-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__.py0
-rw-r--r--hosts/surtr/email/password-server/password_server/__main__.py268
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 @@
1from systemd.daemon import listen_fds
2from sdnotify import SystemdNotifier
3import sys
4import logging
5from threading import Thread
6import uvicorn
7import argparse
8from functools import partial
9from inspect import signature
10from starlette.applications import Starlette
11from starlette.routing import Route, Mount
12from starlette.responses import PlainTextResponse, HTMLResponse
13from starlette.datastructures import State, Secret
14from starlette.middleware import Middleware
15from starlette.middleware.authentication import AuthenticationMiddleware
16from contextlib import asynccontextmanager
17from pathlib import Path
18import os
19from psycopg_pool import ConnectionPool
20from psycopg.rows import namedtuple_row
21from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser, requires
22from starlette.requests import HTTPConnection
23from cryptography.hazmat.primitives import hashes
24from cryptography.hazmat.primitives.hmac import HMAC
25from cryptography.hazmat.primitives.kdf.hkdf import HKDF
26from cryptography.hazmat.primitives.asymmetric import ed25519
27from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
28import jwt
29from datetime import datetime, timezone, timedelta
30from base64 import b64encode
31from xkcdpass import xkcd_password as xp
32from cryptography.hazmat.primitives import constant_time
33
34class 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")
66def 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
82async 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
141def 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
152def 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
161def derive_jwk(secret):
162 return ed25519.Ed25519PrivateKey.from_private_bytes(derive_key(secret, b'jwk', length=32))
163
164def 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
190def 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
201def 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
267if __name__ == '__main__':
268 sys.exit(main())