From 56cbd6f35bb1a8f7a207c9938f682a88ac6915d5 Mon Sep 17 00:00:00 2001 From: missytake Date: Tue, 8 Jul 2025 16:30:54 +0200 Subject: [PATCH 01/11] doveauth: add invite_token to override nocreate file --- chatmaild/src/chatmaild/config.py | 1 + chatmaild/src/chatmaild/doveauth.py | 7 ++++--- chatmaild/src/chatmaild/newemail.py | 5 ++++- cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index ae5f4423..332c1029 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -31,6 +31,7 @@ def __init__(self, inipath, params): self.username_min_length = int(params["username_min_length"]) self.username_max_length = int(params["username_max_length"]) self.password_min_length = int(params["password_min_length"]) + self.invite_token = params["invite_token"] self.passthrough_senders = params["passthrough_senders"].split() self.passthrough_recipients = params["passthrough_recipients"].split() self.www_folder = params.get("www_folder", "") diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index e6292a36..70589214 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -24,10 +24,11 @@ def encrypt_password(password: str): def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: """Return True if user and password are admissable.""" if os.path.exists(NOCREATE_FILE): - logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.") - return False + if config.invite_token and config.invite_token not in cleartext_password: + logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.") + return False - if len(cleartext_password) < config.password_min_length: + if len(cleartext_password.replace(config.invite_token, "")) < config.password_min_length: logging.warning( "Password needs to be at least %s characters long", config.password_min_length, diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index fbf976af..cf483ff2 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -3,6 +3,7 @@ """CGI script for creating new accounts.""" import json +import os import random import secrets import string @@ -20,7 +21,9 @@ def create_newemail_dict(config: Config): secrets.choice(ALPHANUMERIC_PUNCT) for _ in range(config.password_min_length + 3) ) - return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") + redirect_uri = os.getenv("REQUEST_URI") + invite_token = redirect_uri[5:] if redirect_uri != "/new" else "" + return dict(email=f"{user}@{config.mail_domain}", password=f"{invite_token}{password}") def print_new_account(): diff --git a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index 8d27394c..7e72093f 100644 --- a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 +++ b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 @@ -90,6 +90,7 @@ http { fastcgi_pass unix:/run/fcgiwrap.socket; include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py; + fastcgi_param QUERY_STRING $query_string; } # Old URL for compatibility with e.g. printed QR codes. From 1afdab7b205064ecfa8a43e46d02402c70cbf88a Mon Sep 17 00:00:00 2001 From: missytake Date: Tue, 8 Jul 2025 16:44:28 +0200 Subject: [PATCH 02/11] fix lint --- chatmaild/src/chatmaild/config.py | 2 +- chatmaild/src/chatmaild/doveauth.py | 9 +++++++-- chatmaild/src/chatmaild/newemail.py | 8 +++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 332c1029..68d11f75 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -31,7 +31,7 @@ def __init__(self, inipath, params): self.username_min_length = int(params["username_min_length"]) self.username_max_length = int(params["username_max_length"]) self.password_min_length = int(params["password_min_length"]) - self.invite_token = params["invite_token"] + self.invite_token = params.get("invite_token", "") self.passthrough_senders = params["passthrough_senders"].split() self.passthrough_recipients = params["passthrough_recipients"].split() self.www_folder = params.get("www_folder", "") diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index 70589214..f1cc38fe 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -25,10 +25,15 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: """Return True if user and password are admissable.""" if os.path.exists(NOCREATE_FILE): if config.invite_token and config.invite_token not in cleartext_password: - logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.") + logging.warning( + f"blocked account creation because {NOCREATE_FILE!r} exists." + ) return False - if len(cleartext_password.replace(config.invite_token, "")) < config.password_min_length: + if ( + len(cleartext_password.replace(config.invite_token, "")) + < config.password_min_length + ): logging.warning( "Password needs to be at least %s characters long", config.password_min_length, diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index cf483ff2..6ca39cba 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -21,9 +21,11 @@ def create_newemail_dict(config: Config): secrets.choice(ALPHANUMERIC_PUNCT) for _ in range(config.password_min_length + 3) ) - redirect_uri = os.getenv("REQUEST_URI") - invite_token = redirect_uri[5:] if redirect_uri != "/new" else "" - return dict(email=f"{user}@{config.mail_domain}", password=f"{invite_token}{password}") + redirect_uri = os.getenv("REQUEST_URI", "/new") + invite_token = "" if redirect_uri == "/new" else redirect_uri[5:] + return dict( + email=f"{user}@{config.mail_domain}", password=f"{invite_token}{password}" + ) def print_new_account(): From a92c9ff275b8b2e11d4099b4d8c05677535bcaeb Mon Sep 17 00:00:00 2001 From: missytake Date: Wed, 9 Jul 2025 01:19:46 +0200 Subject: [PATCH 03/11] tests: ensure valid invite token in password overrides nocreate file --- chatmaild/src/chatmaild/doveauth.py | 2 +- .../src/chatmaild/tests/test_doveauth.py | 34 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index f1cc38fe..7127ef58 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -24,7 +24,7 @@ def encrypt_password(password: str): def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: """Return True if user and password are admissable.""" if os.path.exists(NOCREATE_FILE): - if config.invite_token and config.invite_token not in cleartext_password: + if not config.invite_token or config.invite_token not in cleartext_password: logging.warning( f"blocked account creation because {NOCREATE_FILE!r} exists." ) diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index 078bec42..4feb44cf 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -64,12 +64,34 @@ def test_dont_overwrite_password_on_wrong_login(dictproxy): assert res["password"] == res2["password"] -def test_nocreate_file(monkeypatch, tmpdir, dictproxy): - p = tmpdir.join("nocreate") - p.write("") - monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) - dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik") - assert not dictproxy.lookup_userdb("newuser12@chat.example.org") +@pytest.mark.parametrize( + ["nocreate_file", "account", "invite_token", "password"], + [ + (False, True, "asdf", "asdfasdmaimfelsgwerw"), + (False, True, "asdf", "z9873240187420913798"), + (False, True, "", "dsaiujfw9fjiwf9w"), + (True, True, "asdf", "asdfmosadkdkfwdofkw"), + (True, False, "asdf", "z9873240187420913798"), + (True, False, "", "dsaiujfw9fjiwf9w"), + ], +) +def test_nocreate_file( + monkeypatch, + tmpdir, + dictproxy, + example_config, + nocreate_file: bool, + account: bool, + invite_token: str, + password: str, +): + if nocreate_file: + p = tmpdir.join("nocreate") + p.write("") + monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) + example_config.invite_token = invite_token + dictproxy.lookup_passdb("newuser12@chat.example.org", password) + assert bool(dictproxy.lookup_userdb("newuser12@chat.example.org")) == account def test_handle_dovecot_request(dictproxy): From de139bde1843b211dc3049664afd13f60200dc1c Mon Sep 17 00:00:00 2001 From: missytake Date: Wed, 9 Jul 2025 09:01:05 +0200 Subject: [PATCH 04/11] nginx: pass on invite tokens even for GET requests --- cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index 7e72093f..644b35b8 100644 --- a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 +++ b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 @@ -84,7 +84,7 @@ http { if ($request_method = GET) { # Redirect to Delta Chat, # which will in turn do a POST request. - return 301 dcaccount:https://{{ config.domain_name }}/new; + return 301 dcaccount:https://{{ config.domain_name }}$request_uri; } fastcgi_pass unix:/run/fcgiwrap.socket; @@ -101,7 +101,7 @@ http { # Redirects are only for browsers. location /cgi-bin/newemail.py { if ($request_method = GET) { - return 301 dcaccount:https://{{ config.domain_name }}/new; + return 301 dcaccount:https://{{ config.domain_name }}$request_uri; } fastcgi_pass unix:/run/fcgiwrap.socket; From 6940175b06e2137b7c06d1c9df67dd84ce04effb Mon Sep 17 00:00:00 2001 From: missytake Date: Wed, 9 Jul 2025 12:42:25 +0200 Subject: [PATCH 05/11] add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13dc2a49..8650e6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## untagged +- Enable invite-only chatmail relays with invite tokens + that can override disabled account creation + ([#600](https://github.com/chatmail/relay/pull/600)) + - dovecot: keep mailbox index only in memory to avoid unnecessary disc usage ([#632](https://github.com/chatmail/relay/pull/632)) From 7dcd109becb8811f4edf97446361a7d0680edfe5 Mon Sep 17 00:00:00 2001 From: missytake Date: Fri, 12 Sep 2025 01:23:20 +0200 Subject: [PATCH 06/11] doveauth: invite token doesn't overwrite nocreate file, must be at beginning of password --- chatmaild/src/chatmaild/doveauth.py | 21 +++++++++++-------- .../src/chatmaild/tests/test_doveauth.py | 5 +++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index 7127ef58..b6217e87 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -24,16 +24,19 @@ def encrypt_password(password: str): def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: """Return True if user and password are admissable.""" if os.path.exists(NOCREATE_FILE): - if not config.invite_token or config.invite_token not in cleartext_password: - logging.warning( - f"blocked account creation because {NOCREATE_FILE!r} exists." - ) - return False + logging.warning( + f"blocked account creation because {NOCREATE_FILE!r} exists." + ) + return False + if cleartext_password.startswith(config.invite_token): + password_length = len(cleartext_password) - len(config.invite_token) + else: + logging.warning( + f"blocked account creation because password didn't contain invite token(s)." + ) + return False - if ( - len(cleartext_password.replace(config.invite_token, "")) - < config.password_min_length - ): + if password_length < config.password_min_length: logging.warning( "Password needs to be at least %s characters long", config.password_min_length, diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index 4feb44cf..6d8052ec 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -68,9 +68,10 @@ def test_dont_overwrite_password_on_wrong_login(dictproxy): ["nocreate_file", "account", "invite_token", "password"], [ (False, True, "asdf", "asdfasdmaimfelsgwerw"), - (False, True, "asdf", "z9873240187420913798"), + (False, False, "asdf", "z9873240187420913798"), (False, True, "", "dsaiujfw9fjiwf9w"), - (True, True, "asdf", "asdfmosadkdkfwdofkw"), + (False, False, "asdf", "z987324018742asdf0913798"), + (True, False, "asdf", "asdfmosadkdkfwdofkw"), (True, False, "asdf", "z9873240187420913798"), (True, False, "", "dsaiujfw9fjiwf9w"), ], From 7319977527045aaea5d7a57ceda9345988f6f2a0 Mon Sep 17 00:00:00 2001 From: missytake Date: Fri, 12 Sep 2025 01:27:28 +0200 Subject: [PATCH 07/11] doveauth: allow more than one invite token --- chatmaild/src/chatmaild/doveauth.py | 18 +++++++++++------- chatmaild/src/chatmaild/tests/test_doveauth.py | 3 +++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index b6217e87..4d143aad 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -28,13 +28,17 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: f"blocked account creation because {NOCREATE_FILE!r} exists." ) return False - if cleartext_password.startswith(config.invite_token): - password_length = len(cleartext_password) - len(config.invite_token) - else: - logging.warning( - f"blocked account creation because password didn't contain invite token(s)." - ) - return False + password_length = len(cleartext_password) + if config.invite_token: + for inv_token in config.invite_token.split(): + if cleartext_password.startswith(inv_token): + password_length = len(cleartext_password) - len(inv_token) + break + else: + logging.warning( + f"blocked account creation because password didn't contain invite token(s)." + ) + return False if password_length < config.password_min_length: logging.warning( diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index 6d8052ec..3f689e5d 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -71,6 +71,9 @@ def test_dont_overwrite_password_on_wrong_login(dictproxy): (False, False, "asdf", "z9873240187420913798"), (False, True, "", "dsaiujfw9fjiwf9w"), (False, False, "asdf", "z987324018742asdf0913798"), + (False, True, "as df", "asj0wiefkj0ofkeefok"), + (False, True, "as df", "dfj0wiefkj0ofkeefok"), + (False, False, "as df", "j0wiefkj0ofas dfkeefok"), (True, False, "asdf", "asdfmosadkdkfwdofkw"), (True, False, "asdf", "z9873240187420913798"), (True, False, "", "dsaiujfw9fjiwf9w"), From f578704069d83008347c6a8a5e6dc007df8e0e4e Mon Sep 17 00:00:00 2001 From: missytake Date: Fri, 12 Sep 2025 01:38:02 +0200 Subject: [PATCH 08/11] doc: document invite tokens --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f60d113..acfb43df 100644 --- a/README.md +++ b/README.md @@ -284,8 +284,22 @@ Fresh chatmail addresses have a mailbox directory that contains: will typically be empty unless the user of that address hasn't been online for a while. +## Restrict address creation -## Emergency Commands to disable automatic address creation +### Only allow new addresses with an invite token + +To restrict address creation for anyone who doesn't have the invite link/QR code: + +1. Use the `invite_token` option to add + one or more tokens of your choice to `chatmail.ini`: + `invite_token = s3cr3t privil3g3` +2. Run `scripts/cmdeploy run` +3. Distribute a `dcaccount` invite link/QR code + (like the one on your web page) + with one of your invite tokens added at the end, + for example: `dcaccount:https://example.org/new?s3cr3t` + +### Emergency Command to disable automatic address creation If you need to stop address creation, e.g. because some script is wildly creating addresses, From 5b4eb1701eeabfd3e171275a1947d861fc439c34 Mon Sep 17 00:00:00 2001 From: missytake Date: Fri, 12 Sep 2025 01:43:16 +0200 Subject: [PATCH 09/11] CI: cmdeploy fmt --- chatmaild/src/chatmaild/doveauth.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index 4d143aad..e2543b7d 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -24,9 +24,7 @@ def encrypt_password(password: str): def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: """Return True if user and password are admissable.""" if os.path.exists(NOCREATE_FILE): - logging.warning( - f"blocked account creation because {NOCREATE_FILE!r} exists." - ) + logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.") return False password_length = len(cleartext_password) if config.invite_token: @@ -36,7 +34,7 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: break else: logging.warning( - f"blocked account creation because password didn't contain invite token(s)." + "blocked account creation because password didn't contain invite token(s)." ) return False From cb87c85c03ccc8b2498a8e781a43e6dc362d7ff0 Mon Sep 17 00:00:00 2001 From: missytake Date: Fri, 12 Sep 2025 09:16:43 +0200 Subject: [PATCH 10/11] doc: add recommendation on token length --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index acfb43df..263e5ce7 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,7 @@ To restrict address creation for anyone who doesn't have the invite link/QR code 1. Use the `invite_token` option to add one or more tokens of your choice to `chatmail.ini`: `invite_token = s3cr3t privil3g3` + - (recommendation: choose 9 or more letters, or it will be easily bruteforced) 2. Run `scripts/cmdeploy run` 3. Distribute a `dcaccount` invite link/QR code (like the one on your web page) From 4a7b4259580b9786b653d4f704c6122d1efe1604 Mon Sep 17 00:00:00 2001 From: missytake Date: Fri, 12 Sep 2025 09:29:48 +0200 Subject: [PATCH 11/11] www: if invite_token is set, don't show the QR code & register button --- www/src/index.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/www/src/index.md b/www/src/index.md index e167c740..0031d28e 100644 --- a/www/src/index.md +++ b/www/src/index.md @@ -11,6 +11,7 @@ for Delta Chat users. For details how it avoids storing personal information please see our [privacy policy](privacy.html). {% endif %} +{% if not config.invite_token %} Get a {{config.mail_domain}} chat profile If you are viewing this page on a different device @@ -23,6 +24,10 @@ you can also **scan this QR code** with Delta Chat: 🐣 **Choose** your Avatar and Name 💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee) +{% else %} +**To join this instance, you need an invite link or QR code - +ask the admin for an invite.** +{% endif %} {% if config.mail_domain != "nine.testrun.org" %}
Note: this is only a temporary development chatmail service