From c6fd7ea89b6e0a96eb1d81989de891b4768b1044 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 31 Aug 2026 17:19:17 +0200 Subject: feat: email verification for registration, email change, and invitations Registration now creates a pending account and sends a 6-digit code via email; the account activates only after verification. Email changes on the profile page follow the same flow. Group invitations send a notification email to the invitee (without revealing their address to the inviter) containing the invite code and hub link. Backend: blind HMAC-SHA256 email index for uniqueness without decryption, mail.py for localhost Postfix delivery, verification endpoints, cleanup of expired codes and stale pending accounts, startup backfill of email_hash for existing users. Frontend: 3-phase register page, inline email change verification on profile, invite-notify call with status display. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 --- docs/MAIL-SERVER.md | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/MAIL-SERVER.md (limited to 'docs') diff --git a/docs/MAIL-SERVER.md b/docs/MAIL-SERVER.md new file mode 100644 index 0000000..c5a0d17 --- /dev/null +++ b/docs/MAIL-SERVER.md @@ -0,0 +1,218 @@ +# Mail Server — Postfix send-only with SPF/DKIM/DMARC + +> Target: meshbay.org (Ubuntu 26.04 LTS, OVH VPS 164.132.246.44) +> +> Purpose: send confirmation codes to validate user email addresses at +> registration. The hub sends mail; it does not receive it. OVH MX servers +> (`mx1.mail.ovh.net` etc.) continue handling inbound mail for the domain. + +--- + +## 1. Current DNS state (before setup) + +| Record | Value | Status | +|---|---|---| +| A | `164.132.246.44` | OK | +| MX | `1 mx1.mail.ovh.net` / `5 mx2` / `100 mx3` | OK — receiving stays OVH | +| SPF | `v=spf1 include:mx.ovh.com -all` | Must add VPS IP | +| DKIM | (none) | Must create | +| DMARC | (none) | Must create | +| PTR (rDNS) | `vps-7148538e.vps.ovh.net` | Must change to `meshbay.org` | + +## 2. What must happen + +### 2.1 Reverse DNS (PTR) + +Set via the **OVH control panel** (not DNS zone): + +1. OVH Manager → Bare Metal Cloud → VPS → IP +2. Click the gear icon next to `164.132.246.44` → Modify reverse DNS +3. Set to: `meshbay.org.` (trailing dot) + +Most receiving MTAs reject or score down mail from an IP whose PTR does not +match the HELO/EHLO hostname. + +### 2.2 DNS zone records (OVH DNS zone editor) + +**Update SPF** — add the VPS IP alongside the existing OVH include: + +``` +meshbay.org. TXT "v=spf1 ip4:164.132.246.44 include:mx.ovh.com -all" +``` + +**Add DKIM** — after running the install script which generates the key: + +``` +meshbay._domainkey.meshbay.org. TXT "v=DKIM1; h=sha256; k=rsa; p=" +``` + +The script prints the exact record content. The selector is `meshbay`. + +**Add DMARC** — start with `none` policy (monitoring), tighten to `quarantine` +then `reject` after confirming deliverability: + +``` +_dmarc.meshbay.org. TXT "v=DMARC1; p=none; sp=none; adkim=s; aspf=s; rua=mailto:postmaster@meshbay.org" +``` + +Target (after validation): +``` +_dmarc.meshbay.org. TXT "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:postmaster@meshbay.org" +``` + +### 2.3 Server-side (automated by the install script) + +| Component | Role | +|---|---| +| **Postfix** | MTA — sends mail directly to recipient MX servers | +| **OpenDKIM** | Signs outgoing mail with the domain's DKIM private key | + +Postfix is configured as **send-only** (no listening on port 25 from outside). + +--- + +## 3. Installation + +Run the provided script on the server: + +```bash +# From the local machine: +scp QE/deploy/setup-mailserver.sh cbesson@meshbay.org:/tmp/ +ssh cbesson@meshbay.org 'sudo bash /tmp/setup-mailserver.sh' +``` + +Or directly on the server: + +```bash +sudo bash setup-mailserver.sh +``` + +The script: +1. Installs Postfix + OpenDKIM +2. Configures Postfix as send-only (inet_interfaces = loopback-only) +3. Generates a 2048-bit RSA DKIM key with selector `meshbay` +4. Wires OpenDKIM into Postfix via milter +5. Prints the DKIM TXT record to add to DNS +6. Sends a test email (if an address is given as argument) + +## 4. Verification + +### 4.1 Local checks + +```bash +# Postfix is running and only on loopback +sudo ss -tlnp | grep :25 +# Expected: 127.0.0.1:25, [::1]:25 only + +# OpenDKIM is running +sudo systemctl status opendkim + +# Send a test +echo "MeshBay mail test" | mail -s "Test from meshbay.org" your@email.com +``` + +### 4.2 Check DKIM signing + +```bash +# Examine the Postfix log for DKIM signing confirmation +sudo journalctl -u postfix@- --since "5 minutes ago" | grep -i dkim +``` + +### 4.3 External validation + +After DNS propagation (up to 24h, usually 1-2h on OVH): + +- **SPF:** `dig TXT meshbay.org` — must show `ip4:164.132.246.44` +- **DKIM:** `dig TXT meshbay._domainkey.meshbay.org` — must return the public key +- **DMARC:** `dig TXT _dmarc.meshbay.org` — must return the policy +- **Full test:** send an email to `check-auth@verifier.port25.com` — the auto-reply + shows SPF/DKIM/DMARC pass/fail for each +- **Alternative:** https://www.mail-tester.com — send to the address shown, get a + score out of 10 + +### 4.4 PTR check + +```bash +dig -x 164.132.246.44 +short +# Expected: meshbay.org. +``` + +## 5. Integration with MeshBay hub + +The hub sends email via `localhost:25` (Postfix). No authentication needed — +Postfix listens only on loopback. Python code uses `smtplib`: + +```python +import smtplib +from email.message import EmailMessage + +def send_confirmation(to: str, code: str) -> None: + msg = EmailMessage() + msg["From"] = "noreply@meshbay.org" + msg["To"] = to + msg["Subject"] = "MeshBay — Confirm your email" + msg.set_content( + f"Your confirmation code is: {code}\n\n" + "This code expires in 30 minutes.\n" + "If you did not create a MeshBay account, ignore this email.\n" + ) + with smtplib.SMTP("localhost", 25) as s: + s.send_message(msg) +``` + +The `From` address must be `@meshbay.org` — it must match SPF and DKIM signing +domain, or the message will fail authentication at the receiver. + +## 6. Security considerations + +- **Postfix is send-only.** `inet_interfaces = loopback-only` means it does not + accept connections from outside. No inbound port 25 in UFW. +- **No relay.** `mynetworks` is loopback only. The server cannot be used as an + open relay. +- **Rate limiting.** Not configured at the Postfix level (low volume, confirmation + codes only). Rate limiting should be done at the application level — the hub + should enforce per-IP and per-account rate limits on the confirmation endpoint. +- **DKIM private key.** Stored at `/etc/opendkim/keys/meshbay.org/meshbay.private`, + owned by `opendkim:opendkim`, mode `0600`. + +## 7. Maintenance + +### Rotate DKIM key + +If the key is compromised or as a routine rotation (yearly is common): + +```bash +sudo opendkim-genkey -b 2048 -d meshbay.org -s meshbay-2025 -D /etc/opendkim/keys/meshbay.org/ +# Update /etc/opendkim/KeyTable with the new selector +# Add the new DKIM DNS record (new selector) +# Keep the old record for 48h so in-flight mail still verifies +# Remove the old record +sudo systemctl restart opendkim postfix +``` + +### Tighten DMARC + +**Why this matters.** `p=none` tells receiving servers: "if a mail fails SPF+DKIM, +deliver it anyway — just report it to me." It protects nobody. An attacker can +send a phishing email `From: noreply@meshbay.org` from their own server and it +will land in the recipient's inbox normally. `p=reject` tells receivers to +**refuse** such mail outright — a fake MeshBay confirmation email with a link to +a credential-harvesting site never reaches the user. This is the defense against +someone impersonating the domain to steal user passphrases. + +The gradual rollout exists only to verify that legitimate mail is not +accidentally blocked before locking the policy down. + +Once deliverability is confirmed (SPF pass, DKIM pass on test emails): + +1. `p=none` → `p=quarantine` — wait 2 weeks, check `rua` reports +2. `p=quarantine` → `p=reject` — the final target + +## 8. Fedora 44 notes + +The install script auto-detects Fedora and uses `dnf` instead of `apt`. +OpenDKIM is in the `opendkim` package, same as Ubuntu. The main difference is +the Postfix service name (`postfix` on both) and that SELinux may need a policy +for the OpenDKIM socket. The script handles this with `setsebool -P +dkim_milter_enable on` if SELinux is enforcing, and falls back to a custom +module if the boolean does not exist. -- cgit v1.2.3