feat: move doveauth from dictproxy to lua/http

1. existing logins are now verified by lua only

2. non-existing logins are delegated to the new Python doveauth http /create endpoint

Using Lua and http this way makes doveauth more compatible to dovecot 2.4
This commit is contained in:
holger krekel
2026-09-01 22:56:07 +02:00
parent 051f831518
commit 2d0fc2e70e
18 changed files with 593 additions and 296 deletions
+1
View File
@@ -55,6 +55,7 @@ class Config:
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.pop("postfix_reinject_port_incoming", "10026") params.pop("postfix_reinject_port_incoming", "10026")
) )
self.doveauth_http_port = int(params.pop("doveauth_http_port", "10084"))
self.mtail_address = params.pop("mtail_address", None) self.mtail_address = params.pop("mtail_address", None)
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true"
self.acme_email = params.pop("acme_email", "") self.acme_email = params.pop("acme_email", "")
+110 -96
View File
@@ -1,10 +1,17 @@
import json """Create chatmail addresses on first login.
Dovecot only asks us about addresses it does not already find in the mailbox:
the auth.lua we deploy with dovecot (cmdeploy/src/cmdeploy/dovecot/auth.lua.j2)
verifies existing users itself against a mailbox password file,
and HTTP-POSTs everything else to the /create endpoint implemented in this module.
"""
import logging import logging
import os import os
import re import re
import sys import sys
import threading
import filelock from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
try: try:
import crypt_r import crypt_r
@@ -12,7 +19,6 @@ except ImportError:
import crypt as crypt_r import crypt as crypt_r
from .config import Config, read_config from .config import Config, read_config
from .dictproxy import DictProxy
from .migrate_db import migrate_from_db_to_maildir from .migrate_db import migrate_from_db_to_maildir
from .syslimits import has_sufficient_resources from .syslimits import has_sufficient_resources
@@ -64,109 +70,117 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def split_and_unescape(s): def verify_password(stored, cleartext_password) -> bool:
"""Split strings using double quote as a separator and backslash as escape character if stored.startswith("{"):
into parts.""" stored = stored.split("}", 1)[1]
return crypt_r.crypt(cleartext_password, stored) == stored
out = ""
i = 0
while i < len(s):
c = s[i]
if c == "\\":
# Skip escape character.
i += 1
# This will raise IndexError if there is no character
# after escape character. This is expected
# as this is an invalid input.
out += s[i]
elif c == '"':
# Separator
yield out
out = ""
else:
out += c
i += 1
yield out
class AuthDictProxy(DictProxy): class DoveAuth:
def __init__(self, config): def __init__(self, config):
super().__init__()
self.config = config self.config = config
self.creation_lock = threading.Lock()
def handle_lookup(self, parts): def create_user(self, addr, cleartext_password) -> bool:
# Dovecot <2.3.17 has only one part, """Create the address, or verify the password if it exists already."""
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
config = self.config config = self.config
reply_command = "F" if not addr.endswith(f"@{config.mail_domain}"):
res = "" logging.warning("address not in mail domain: %r", addr)
if namespace == "shared": return False
if type == "userdb": try:
user = args[0] user = config.get_user(addr)
if user.endswith(f"@{config.mail_domain}"): except ValueError:
res = self.lookup_userdb(user) logging.warning("invalid address: %r", addr)
if res: return False
reply_command = "O" with self.creation_lock:
else: passhash = user.get_password_hash()
reply_command = "N" if passhash is not None:
elif type == "passdb": # a concurrent first login may have just created the address
user = args[1] return verify_password(passhash, cleartext_password)
if user.endswith(f"@{config.mail_domain}"): if not is_allowed_to_create(config, addr, cleartext_password):
res = self.lookup_passdb(user, cleartext_password=args[0]) return False
if res: if not has_sufficient_resources(config):
reply_command = "O" return False
else:
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
def handle_iterate(self, parts):
# example: I0\t0\tshared/userdb/
if parts[2] == "shared/userdb/":
result = "".join(
f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
)
return f"{result}\n"
def iter_userdb(self) -> list:
"""Get a list of all user addresses."""
return [x for x in os.listdir(self.config.mailboxes_dir) if "@" in x]
def lookup_userdb(self, addr):
return self.config.get_user(addr).get_userdb_dict()
def lookup_passdb(self, addr, cleartext_password):
user = self.config.get_user(addr)
userdata = user.get_userdb_dict()
if userdata:
return userdata
if not is_allowed_to_create(self.config, addr, cleartext_password):
return
if not has_sufficient_resources(self.config):
return
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
with lock:
userdata = user.get_userdb_dict()
if userdata:
return userdata
user.set_password(encrypt_password(cleartext_password)) user.set_password(encrypt_password(cleartext_password))
print(f"Created address: {addr}", file=sys.stderr) # mtail counts created_accounts off this exact line
return user.get_userdb_dict() print(f"Created address: {addr}", file=sys.stderr)
return True
class CreateHandler(BaseHTTPRequestHandler):
"""Answer POST /create requests from dovecot's auth.lua, body `addr\\tpassword`.
The body must be UTF-8 and only the first tab separates the fields,
so a password may itself contain tabs.
Any non-UTF8 or \\0 bytes in the body fail the request.
Addresses are ASCII: dovecot refuses any login name outside its
auth_username_chars before auth.lua ever sees it.
Dovecot hands auth.lua the exact password bytes the client sent;
decoding and re-encoding UTF-8 is byte-identical,
so dovecot's password_verify later recomputes the same hash crypt() stores here.
"""
protocol_version = "HTTP/1.1" # dovecot's HTTP client reuses connections
max_body_len = 512 # an address and a password
def do_POST(self):
if self.path != "/create":
self.reply(404)
return
length = self.body_length()
if length is None:
self.reply(400)
return
body = self.rfile.read(length)
try:
addr, _, password = body.decode("utf-8").partition("\t")
except UnicodeDecodeError:
self.reply(400)
return
if "\0" in addr or "\0" in password:
self.reply(400)
return
self.reply(200 if self.server.doveauth.create_user(addr, password) else 403)
def body_length(self):
try:
length = int(self.headers["Content-Length"])
except (TypeError, ValueError):
return None
return length if 0 <= length <= self.max_body_len else None
def reply(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
if status != 200:
# Just close on any failure, as body might not be fully read.
# It's anyway cheap to re-establish http localhost without TLS.
self.send_header("Connection", "close")
self.end_headers()
def log_message(self, format, *args):
# the per-request access log would only duplicate our own stderr lines
pass
class DoveAuthServer(ThreadingHTTPServer):
# a burst of first-time logins (e.g. from CI) must not overflow
# the accept queue, see https://github.com/chatmail/relay/issues/436
request_queue_size = 1000
def __init__(self, config, port):
super().__init__(("127.0.0.1", port), CreateHandler)
self.doveauth = DoveAuth(config)
def main(): def main():
socket, cfgpath = sys.argv[1:] (cfgpath,) = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
migrate_from_db_to_maildir(config) migrate_from_db_to_maildir(config)
dictproxy = AuthDictProxy(config=config) server = DoveAuthServer(config, config.doveauth_http_port)
server.serve_forever()
dictproxy.serve_forever_from_socket(socket)
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import AuthDictProxy from chatmaild.doveauth import DoveAuth
from chatmaild.expire import daily_expire_main as main_expire from chatmaild.expire import daily_expire_main as main_expire
@@ -18,10 +18,10 @@ def test_login_timestamps(example_config):
def test_delete_inactive_users(example_config): def test_delete_inactive_users(example_config):
new = time.time() new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1 old = new - (example_config.delete_inactive_users_after * 86400) - 1
dictproxy = AuthDictProxy(example_config) doveauth = DoveAuth(example_config)
def create_user(addr, last_login): def create_user(addr, last_login):
dictproxy.lookup_passdb(addr, "q9mr3faue") doveauth.create_user(addr, "q9mr3faue")
user = example_config.get_user(addr) user = example_config.get_user(addr)
user.maildir.joinpath("cur").mkdir() user.maildir.joinpath("cur").mkdir()
user.maildir.joinpath("cur", "something").mkdir() user.maildir.joinpath("cur", "something").mkdir()
+166 -142
View File
@@ -1,42 +1,37 @@
import io import http.client
import json
import queue
import threading import threading
import traceback from concurrent.futures import ThreadPoolExecutor
import pytest import pytest
import chatmaild.doveauth import chatmaild.doveauth
from chatmaild.doveauth import ( from chatmaild.doveauth import (
AuthDictProxy, CreateHandler,
DoveAuth,
DoveAuthServer,
is_allowed_to_create, is_allowed_to_create,
) )
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
@pytest.fixture @pytest.fixture
def dictproxy(example_config): def doveauth(example_config):
return AuthDictProxy(config=example_config) return DoveAuth(example_config)
def test_basic(dictproxy, example_gencreds): def stored_hash(config, addr):
return config.get_user(addr).get_password_hash()
def test_basic(doveauth, example_config, example_gencreds):
addr, password = example_gencreds() addr, password = example_gencreds()
dictproxy.lookup_passdb(addr, password) assert doveauth.create_user(addr, password)
data = dictproxy.lookup_userdb(addr) passhash = stored_hash(example_config, addr)
assert data assert passhash.startswith("{SHA512-CRYPT}")
data2 = dictproxy.lookup_passdb(addr, password)
assert data == data2
# a second login verifies against the stored hash and rewrites nothing
def test_iterate_addresses(dictproxy): assert doveauth.create_user(addr, password)
addresses = [] assert stored_hash(example_config, addr) == passhash
for i in range(10):
addresses.append(f"asdf1234{i}@chat.example.org")
dictproxy.lookup_passdb(addresses[-1], "q9mr3faue")
res = dictproxy.iter_userdb()
assert set(res) == set(addresses)
def test_invalid_username_length(example_config): def test_invalid_username_length(example_config):
@@ -53,75 +48,32 @@ def test_invalid_username_length(example_config):
) )
def test_dont_overwrite_password_on_wrong_login(dictproxy): def test_dont_overwrite_password_on_wrong_login(doveauth, example_config):
"""Test that logging in with a different password doesn't create a new user""" addr = "newuser12@chat.example.org"
res = dictproxy.lookup_passdb( assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983")
"newuser12@chat.example.org", "kajdlkajsldk12l3kj1983" passhash = stored_hash(example_config, addr)
)
assert res["password"] assert not doveauth.create_user(addr, "kajdslqwe")
res2 = dictproxy.lookup_passdb("newuser12@chat.example.org", "kajdslqwe") assert stored_hash(example_config, addr) == passhash
# this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"] assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983")
assert stored_hash(example_config, addr) == passhash
def test_nocreate_file(monkeypatch, tmpdir, dictproxy): def test_foreign_domain_is_refused(doveauth):
assert not doveauth.create_user("newuser12@evil.example.org", "qlwkejqlwe12")
def test_nocreate_file(monkeypatch, tmpdir, doveauth, example_config):
p = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
p.write("") p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik") addr = "newuser12@chat.example.org"
assert not dictproxy.lookup_userdb("newuser12@chat.example.org") assert not doveauth.create_user(addr, "zequ0Aimuchoodaechik")
assert stored_hash(example_config, addr) is None
def test_handle_dovecot_request(dictproxy):
transactions = {}
# Test that password can contain ", ', \ and /
msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org"
)
res = dictproxy.handle_dovecot_request(msg, transactions)
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol_user_not_exists(example_config):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(example_config):
dictproxy = AuthDictProxy(config=example_config)
dictproxy.lookup_passdb("asdf00000@chat.example.org", "q9mr3faue")
dictproxy.lookup_passdb("asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
lines = wfile.getvalue().decode("ascii").split("\n")
assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines
assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines
assert not lines[2]
def test_invalid_localpart_characters(make_config): def test_invalid_localpart_characters(make_config):
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
config = make_config("chat.example.org", {"username_min_length": "3"}) config = make_config("chat.example.org", {"username_min_length": "3"})
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
domain = config.mail_domain domain = config.mail_domain
@@ -141,78 +93,150 @@ def test_invalid_localpart_characters(make_config):
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password) assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
assert not is_allowed_to_create(config, f"abc/def@{domain}", password) assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password) assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
assert not is_allowed_to_create(config, f"üser123@{domain}", password)
def test_concurrent_creation_same_account(dictproxy): def test_concurrent_creation_same_account(doveauth, example_config, capsys):
"""Test that concurrent creation of the same account doesn't corrupt password."""
addr = "racetest1@chat.example.org" addr = "racetest1@chat.example.org"
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
num_threads = 10
results = queue.Queue()
def create(): def create(_):
try: ok = doveauth.create_user(addr, password)
res = dictproxy.lookup_passdb(addr, password) return ok, stored_hash(example_config, addr)
results.put(("ok", res))
except Exception:
results.put(("err", traceback.format_exc()))
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
passwords_seen = set()
for _ in range(num_threads):
status, res = results.get()
if status == "err":
pytest.fail(f"concurrent creation failed\n{res}")
passwords_seen.add(res["password"])
with ThreadPoolExecutor(10) as pool:
results = list(pool.map(create, range(10)))
assert all(ok for ok, _ in results)
# all threads must see the same password hash # all threads must see the same password hash
assert len(passwords_seen) == 1 assert len({passhash for _, passhash in results}) == 1
assert capsys.readouterr().err.count("Created address:") == 1
def test_50_concurrent_lookups_different_accounts(example_gencreds, dictproxy):
num_threads = 50
req_per_thread = 5
results = queue.Queue()
def lookup():
for i in range(req_per_thread):
addr, password = example_gencreds()
try:
dictproxy.lookup_passdb(addr, password)
except Exception:
results.put(traceback.format_exc())
else:
results.put(None)
threads = []
for i in range(num_threads):
thread = threading.Thread(target=lookup, daemon=True)
threads.append(thread)
print(f"created {num_threads} threads, starting them and waiting for results")
for thread in threads:
thread.start()
for i in range(num_threads * req_per_thread):
res = results.get()
if res is not None:
pytest.fail(f"concurrent lookup failed\n{res}")
def test_insufficient_resources_block_creation_not_existing_logins( def test_insufficient_resources_block_creation_not_existing_logins(
dictproxy, example_gencreds, monkeypatch doveauth, example_gencreds, monkeypatch
): ):
addr, password = example_gencreds() addr, password = example_gencreds()
assert dictproxy.lookup_passdb(addr, password) assert doveauth.create_user(addr, password)
monkeypatch.setattr( monkeypatch.setattr(
chatmaild.doveauth, "has_sufficient_resources", lambda config: False chatmaild.doveauth, "has_sufficient_resources", lambda config: False
) )
newaddr, newpassword = example_gencreds() newaddr, newpassword = example_gencreds()
assert not dictproxy.lookup_passdb(newaddr, newpassword) assert not doveauth.create_user(newaddr, newpassword)
assert dictproxy.lookup_passdb(addr, password) assert doveauth.create_user(addr, password)
class TestHttpPost:
@pytest.fixture
def doveauth_server(self, example_config):
server = DoveAuthServer(example_config, port=0)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"127.0.0.1:{server.server_address[1]}"
server.shutdown()
server.server_close()
@pytest.fixture
def post(self, doveauth_server):
def post(path, data):
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
try:
return self.post_on(conn, path, data).status
finally:
conn.close()
return post
@pytest.fixture
def connection(self, doveauth_server):
"""One kept-alive connection, which is all dovecot's HTTP client opens."""
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
yield conn
conn.close()
@staticmethod
def post_on(conn, path, data):
conn.request("POST", path, body=data)
resp = conn.getresponse()
resp.read()
return resp
def test_create_and_verify(self, post, example_config, example_gencreds):
addr, password = example_gencreds()
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert stored_hash(example_config, addr).startswith("{SHA512-CRYPT}")
# second login with the same password verifies, a wrong one is refused
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert post("/create", f"{addr}\twrong{password}".encode()) == 403
def test_password_special_chars_survive_transport(self, post, example_gencreds):
addr, _ = example_gencreds()
password = "laksjdlaksjdlak\\sjdlk\"12j'3l1/k2\tj3123"
body = f"{addr}\t{password}".encode()
assert post("/create", body) == 200
assert post("/create", body) == 200
assert post("/create", f"{addr}\totherpassword1".encode()) == 403
def test_password_must_be_utf8(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpässwort12".encode()) == 200
assert post("/create", addr.encode() + b"\tp\xe4sswort12") == 400
def test_nul_is_refused_before_crypt_sees_it(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpass\0word12".encode()) == 400
assert (
post("/create", "us\0er12345@chat.example.org\tlongenough1".encode()) == 400
)
def test_refused_creation(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tshort".encode()) == 403
assert post("/create", b"not-an-address\tlongenoughpassword") == 403
body = "bürger123@chat.example.org\tlongenoughpw".encode()
assert post("/create", body) == 403
assert post("/create", b"") == 403
def test_body_length_limit(self, post, example_gencreds):
addr, _ = example_gencreds()
fill = CreateHandler.max_body_len - len(addr) - len("\t")
body = f"{addr}\t{'x' * fill}".encode()
assert len(body) == CreateHandler.max_body_len
assert post("/create", body) == 200
body = f"{addr}\t{'x' * (fill + 1)}".encode()
assert len(body) == CreateHandler.max_body_len + 1
assert post("/create", body) == 400
def test_connection_is_reused_across_200_replies(
self, connection, example_gencreds
):
addr, password = example_gencreds()
body = f"{addr}\t{password}".encode()
# create, then verify the same password, on one connection
for _ in range(2):
resp = self.post_on(connection, "/create", body)
assert (resp.status, resp.will_close) == (200, False)
@pytest.mark.parametrize(
"path,data,status",
[
("/other", b"not read", 404),
("/create", b"x" * (CreateHandler.max_body_len + 1), 400),
("/create", b"not-an-address\tlongenoughpassword", 403),
],
)
def test_error_replies_close_the_connection(self, connection, path, data, status):
resp = self.post_on(connection, path, data)
assert (resp.status, resp.will_close) == (status, True)
@pytest.mark.parametrize("content_length", [None, "-1", "notanumber", "999999"])
def test_bad_content_length(self, connection, content_length):
# the fixture timeout turns a server that waits for the body into a failure
connection.putrequest("POST", "/create", skip_accept_encoding=True)
if content_length is not None:
connection.putheader("Content-Length", content_length)
connection.endheaders()
resp = connection.getresponse()
assert resp.status == 400
assert resp.will_close
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import AuthDictProxy from chatmaild.doveauth import DoveAuth
from chatmaild.lastlogin import ( from chatmaild.lastlogin import (
LastLoginDictProxy, LastLoginDictProxy,
) )
@@ -9,8 +9,8 @@ from chatmaild.lastlogin import (
def test_handle_dovecot_request_last_login(testaddr, example_config): def test_handle_dovecot_request_last_login(testaddr, example_config):
dictproxy = LastLoginDictProxy(config=example_config) dictproxy = LastLoginDictProxy(config=example_config)
authproxy = AuthDictProxy(config=example_config) doveauth = DoveAuth(example_config)
authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123") doveauth.create_user(testaddr, "1l2k3j1l2k3jl123")
dictproxy_transactions = {} dictproxy_transactions = {}
@@ -63,7 +63,7 @@ def test_migration(tmp_path, example_config, caplog):
user = example_config.get_user(path.name) user = example_config.get_user(path.name)
if last_login: if last_login:
assert user.get_last_login_timestamp() == last_login assert user.get_last_login_timestamp() == last_login
assert password == user.get_userdb_dict()["password"] assert password == user.get_password_hash()
assert not all assert not all
assert not example_config.passdb_path.exists() assert not example_config.passdb_path.exists()
+6 -11
View File
@@ -8,28 +8,23 @@ def test_login_timestamp(testaddr, example_config):
assert user.get_last_login_timestamp() == 86400 * 2 assert user.get_last_login_timestamp() == 86400 * 2
def test_get_user_dict_not_set(testaddr, example_config, caplog): def test_get_password_hash_not_set(testaddr, example_config, caplog):
user = example_config.get_user(testaddr) user = example_config.get_user(testaddr)
assert not caplog.records assert not caplog.records
assert user.get_userdb_dict() == {} assert user.get_password_hash() is None
assert len(caplog.records) == 0 assert len(caplog.records) == 0
user.set_password("") user.set_password("")
assert user.get_userdb_dict() == {} assert user.get_password_hash() is None
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_get_user_dict(make_config, tmp_path): def test_get_password_hash(make_config, tmp_path):
config = make_config("something.testrun.org") config = make_config("something.testrun.org")
addr = "user1@something.org" user = config.get_user("user1@something.org")
user = config.get_user(addr)
enc_password = "l1k2j31lk2j3l1k23j123" enc_password = "l1k2j31lk2j3l1k23j123"
user.set_password(enc_password) user.set_password(enc_password)
data = user.get_userdb_dict() assert user.get_password_hash() == enc_password
assert addr in str(data["home"])
assert data["uid"] == "vmail"
assert data["gid"] == "vmail"
assert data["password"] == enc_password
def test_no_mailboxes_dir(testaddr, example_config, tmp_path): def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
+6 -9
View File
@@ -21,20 +21,17 @@ class User:
def can_track(self): def can_track(self):
return "@" in self.addr return "@" in self.addr
def get_userdb_dict(self): def get_password_hash(self):
"""Return a non-empty dovecot 'userdb' style dict
if the user has an existing non-empty password"""
try: try:
pw = self.password_path.read_text() passhash = self.password_path.read_text()
except FileNotFoundError: except FileNotFoundError:
return {} return None
if not pw: if not passhash:
logging.error(f"password is empty for: {self.addr}") logging.error(f"password is empty for: {self.addr}")
return {} return None
home = str(self.maildir) return passhash
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
def is_incoming_cleartext_ok(self): def is_incoming_cleartext_ok(self):
return not self.enforce_E2EE_path.exists() return not self.enforce_E2EE_path.exists()
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"pytest-xdist", "pytest-xdist",
"execnet", "execnet",
"imap_tools", "imap_tools",
"jinja2",
"lupa", "lupa",
"deltachat-rpc-client", "deltachat-rpc-client",
"deltachat-rpc-server", "deltachat-rpc-server",
-12
View File
@@ -1,12 +0,0 @@
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = no
iterate_prefix = userdb/
default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/2.3/configuration_manual/config_file/config_variables/#modifiers>
# for documentation.
#
# We escape user-provided input and use double quote as a separator.
password_key = passdb/%Ew"%Eu
user_key = userdb/%Eu
+64
View File
@@ -0,0 +1,64 @@
-- Existing addresses are served from the maildir directly.
-- Unknown ones are offered to doveauth, which owns the creation policy.
local mailboxes_dir = "{{ config.mailboxes_dir }}"
local domain_suffix = "@{{ config.mail_domain }}"
local create_url = "http://127.0.0.1:{{ config.doveauth_http_port }}/create"
local http_client
local function is_ours(user)
return user:sub(-#domain_suffix) == domain_suffix
and not user:find("/", 1, true)
end
local function password_hash(user)
local fh = io.open(mailboxes_dir .. "/" .. user .. "/password", "r")
if not fh then
return nil
end
local hash, rest = fh:read("l", "a")
fh:close()
if hash == nil or hash == "" or rest ~= "" then
return nil
end
return hash
end
local function userdb_fields(user)
return {home = mailboxes_dir .. "/" .. user, uid = "vmail", gid = "vmail"}
end
local function create(user, password)
local request = http_client:request({url = create_url, method = "POST"})
request:set_payload(user .. "\t" .. password)
return request:submit():status() == 200
end
-- Entry points called by dovecot
function script_init()
http_client = dovecot.http.client({request_timeout_msecs = 5000, max_attempts = 1})
return 0
end
function auth_userdb_lookup(req)
if not is_ours(req.user) or password_hash(req.user) == nil then
return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, {}
end
return dovecot.auth.USERDB_RESULT_OK, userdb_fields(req.user)
end
function auth_password_verify(req, password)
if not is_ours(req.user) then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
local hash = password_hash(req.user)
if hash == nil then
if not create(req.user, password) then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
elseif req:password_verify(hash, password) ~= 1 then
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, {}
end
return dovecot.auth.PASSDB_RESULT_OK, userdb_fields(req.user)
end
+3 -2
View File
@@ -31,7 +31,7 @@ class DovecotDeployer(Deployer):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
with blocked_service_startup(): with blocked_service_startup():
debs = [] debs = []
for pkg in ("core", "imapd", "lmtpd"): for pkg in ("core", "imapd", "lmtpd", "auth-lua"):
deb, changed = _download_dovecot_package(pkg, arch) deb, changed = _download_dovecot_package(pkg, arch)
self.need_restart |= changed self.need_restart |= changed
if deb: if deb:
@@ -134,7 +134,8 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
debug=debug, debug=debug,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf") deployer.put_template("dovecot/auth.lua.j2", "/etc/dovecot/auth.lua", config=config)
deployer.remove_file("/etc/dovecot/auth.conf")
deployer.put_file( deployer.put_file(
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua" "dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
) )
@@ -61,12 +61,12 @@ imap_capability = +XDELTAPUSH XCHATMAIL
# Authentication for system users. # Authentication for system users.
passdb { passdb {
driver = dict driver = lua
args = /etc/dovecot/auth.conf args = file=/etc/dovecot/auth.lua blocking=yes
} }
userdb { userdb {
driver = dict driver = lua
args = /etc/dovecot/auth.conf args = file=/etc/dovecot/auth.lua blocking=yes
} }
## ##
## Mailbox locations and namespaces ## Mailbox locations and namespaces
+2
View File
@@ -36,6 +36,8 @@ DOVECOT_SHA256 = {
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", ("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", ("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", ("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
("auth-lua", "amd64"): "d724f37712faba52e177153114af1831e54da555c57c6474c05f96f176176ce4",
("auth-lua", "arm64"): "7272768e20de148c35891d99cd60205acbe7e732b056caf0a83e8194d49136e6",
} }
TURN_VERSION = "v0.4" TURN_VERSION = "v0.4"
TURN_ARTIFACTS = { TURN_ARTIFACTS = {
@@ -1,12 +1,11 @@
[Unit] [Unit]
Description=Chatmail dict authentication proxy for dovecot Description=Chatmail HTTP authentication service for dovecot
[Service] [Service]
ExecStart={execpath} /run/doveauth/doveauth.socket {config_path} ExecStart={execpath} {config_path}
Restart=always Restart=always
RestartSec=30 RestartSec=5
User=vmail User=vmail
RuntimeDirectory=doveauth
UMask=0077 UMask=0077
[Install] [Install]
+206
View File
@@ -0,0 +1,206 @@
"""Test auth.lua script against mocked dovecot auth API."""
import jinja2
import pytest
from chatmaild.doveauth import encrypt_password, verify_password
from cmdeploy.basedeploy import get_resource
USER1 = "user12345@chat.example.org"
USER2 = "newuser12@chat.example.org"
OK, UNKNOWN, MISMATCH = 1, -2, -3
DOVECOT_MOCKS = """
create_status = 200
dovecot = {
auth = {
PASSDB_RESULT_OK = OK,
PASSDB_RESULT_USER_UNKNOWN = UNKNOWN,
PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH,
USERDB_RESULT_OK = OK,
USERDB_RESULT_USER_UNKNOWN = UNKNOWN,
},
http = {
client = function(options)
client_options = options
return {request = function(_, options)
create_request = options
return {
set_payload = function(_, payload) create_payload = payload end,
submit = function()
return {status = function() return create_status end}
end,
}
end}
end,
},
}
"""
def load_authlua(lua, config):
lua.g.OK, lua.g.UNKNOWN, lua.g.MISMATCH = OK, UNKNOWN, MISMATCH
lua.rt.execute(DOVECOT_MOCKS)
template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text())
lua.rt.execute(template.render(config=config))
assert lua.g.script_init() == 0
return lua
@pytest.fixture
def authlua(lua, example_config):
return load_authlua(lua, example_config)
@pytest.fixture
def request_for(lua):
def request_for(addr):
def password_verify(_self, hashed, plain):
return 1 if verify_password(hashed, plain) else 0
return lua.table(user=addr, password_verify=password_verify)
return request_for
@pytest.fixture
def create_user(example_config):
def create_user(addr, password):
example_config.get_user(addr).set_password(encrypt_password(password))
return create_user
@pytest.fixture
def write_password_file(example_config):
def write_password_file(addr, content):
maildir = example_config.mailboxes_dir / addr
maildir.mkdir(parents=True, exist_ok=True)
maildir.joinpath("password").write_text(content)
return write_password_file
def test_http_client_uses_dovecot_setting_names(authlua):
"""dovecot's lua http binding silently ignores keys it does not know."""
assert dict(authlua.g.client_options) == {
"request_timeout_msecs": 5000,
"max_attempts": 1,
}
def test_existing_address_correct_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert fields["uid"] == fields["gid"] == "vmail"
assert fields["home"].endswith(USER1)
assert authlua.g.create_payload is None
def test_existing_address_wrong_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "wronghorse")
assert res == MISMATCH
def test_foreign_domain_is_refused_without_calling_out(
authlua, request_for, create_user
):
create_user("user12345@evil.example.org", "correctgoose")
request = request_for("user12345@evil.example.org")
res, _ = authlua.g.auth_password_verify(request, "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_name_shorter_than_the_domain_is_refused(authlua, request_for):
for name in ("x", "", "chat.example.org"):
res, _ = authlua.g.auth_password_verify(request_for(name), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(name))[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_slash_in_username_is_refused(authlua, request_for):
request = request_for("../../etc/shadow@chat.example.org")
res, _ = authlua.g.auth_password_verify(request, "somepassword")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_localpart_policy_is_left_to_doveauth(authlua, request_for):
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for("@chat.example.org"), "somepw")
assert res == UNKNOWN
assert authlua.g.create_payload == "@chat.example.org\tsomepw"
def test_unknown_address_is_created_via_endpoint(authlua, request_for):
res, fields = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == OK
assert fields["home"].endswith(USER2)
assert authlua.g.create_payload == f"{USER2}\tbrandnewpass"
assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create"
# a policy refusal and a doveauth that is down are both fail-closed
@pytest.mark.parametrize("status", [403, 0])
def test_creation_that_is_not_answered_with_200_is_user_unknown(
authlua, request_for, status
):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == UNKNOWN
def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user):
request = request_for(USER1)
res, _ = authlua.g.auth_userdb_lookup(request)
assert res == UNKNOWN
# a userdb lookup must never create anything
assert authlua.g.create_payload is None
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_userdb_lookup(request)
assert res == OK
assert fields["home"].endswith(USER1)
assert fields["uid"] == fields["gid"] == "vmail"
def test_empty_password_file_is_unknown(authlua, request_for, write_password_file):
write_password_file(USER1, "")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
write_password_file(USER1, "\n")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_password_file_format_checks(authlua, request_for, write_password_file):
write_password_file(USER1, encrypt_password("correctgoose") + "\n")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == OK
passhash = encrypt_password("correctgoose")
write_password_file(USER1, passhash + "\ntrailing junk")
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_ipv4_relay_uses_bracketed_domain(lua, ipv4_config, request_for):
# mail_domain is "[1.3.3.7]" here, and is_ours must not read it as a pattern
authlua = load_authlua(lua, ipv4_config)
addr = f"user12345@{ipv4_config.mail_domain}"
ipv4_config.get_user(addr).set_password(encrypt_password("correctgoose"))
res, fields = authlua.g.auth_password_verify(request_for(addr), "correctgoose")
assert res == OK
assert fields["home"].endswith(addr)
assert authlua.g.auth_userdb_lookup(request_for(addr))[0] == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
@@ -136,6 +136,7 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-auth-lua": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
}, },
), ),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
@@ -180,9 +181,12 @@ def test_install_unsupported_arch_falls_back_to_apt(
deployer.install() deployer.install()
actual_pkgs = [c["packages"] for c in apt_calls] actual_pkgs = [c["packages"] for c in apt_calls]
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], ( assert actual_pkgs == [
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}" ["dovecot-core"],
) ["dovecot-imapd"],
["dovecot-lmtpd"],
["dovecot-auth-lua"],
], f"expected apt install of core/imapd/lmtpd/auth-lua, got {actual_pkgs}"
assert track_shell == [], "should not run dpkg for unsupported arch" assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, ( assert deployer.need_restart is True, (
"need_restart should be True when apt installed a package" "need_restart should be True when apt installed a package"
+7 -6
View File
@@ -84,11 +84,12 @@ and only relaying OpenPGP end-to-end messages encrypted messages. A
short overview of ``chatmaild`` services: short overview of ``chatmaild`` services:
- :repofile:`doveauth <chatmaild/src/chatmaild/doveauth.py>` - :repofile:`doveauth <chatmaild/src/chatmaild/doveauth.py>`
implements create-on-login address semantics and is used by Dovecot implements create-on-login address semantics.
during IMAP login and by Postfix during SMTP/SUBMISSION login which Dovecot authenticates IMAP logins, and Postfix SMTP/SUBMISSION logins through `Dovecot SASL
in turn uses `Dovecot SASL <https://doc.dovecot.org/2.3/configuration_manual/authentication/authentication_mechanisms/>`_,
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_ from an :repofile:`auth.lua <cmdeploy/src/cmdeploy/dovecot/auth.lua.j2>` script
to authenticate logins. that reads the maildir directly. Only addresses which do not exist yet
are passed on to doveauth, which owns the creation policy.
- :repofile:`chatmail-metadata <chatmaild/src/chatmaild/metadata.py>` - :repofile:`chatmail-metadata <chatmaild/src/chatmaild/metadata.py>`
is contacted by a is contacted by a
@@ -155,7 +156,7 @@ Chatmail relay dependency diagram
filtermail-outgoing --- |10025 reinject|postfix; filtermail-outgoing --- |10025 reinject|postfix;
filtermail-incoming --- |10026 reinject|postfix; filtermail-incoming --- |10026 reinject|postfix;
postfix --- |milter opendkim.sock|OpenDKIM postfix --- |milter opendkim.sock|OpenDKIM
dovecot --- |doveauth.socket|doveauth; dovecot --- |10084 create|doveauth;
dovecot --- |message delivery|maildir["maildir dovecot --- |message delivery|maildir["maildir
/home/vmail/.../user"]; /home/vmail/.../user"];
dovecot --- |lastlogin.socket|lastlogin; dovecot --- |lastlogin.socket|lastlogin;