Postfix E-Mail Signierung via GPG

  • System: Debian 13, ISPConfig, Nginx

  • Editor: micro

  • Letzte Änderung: 2026-07-02

1. GPG Schlüssel-Infrastruktur & Rechte

Aus Sicherheitsgründen darf der Postfix-eigene Nutzer (postfix) keine pipe-Dienste mit eigenen Privilegien starten. Der Signatur-Prozess läuft daher unter dem unprivilegierten Systembenutzer nobody:nogroup. GPG setzt zwingend voraus, dass das Home-Verzeichnis dem ausführenden Nutzer gehört und restriktive Rechte besitzt.

Verzeichnisstruktur anlegen & absichern

BASH
# 1. Neues GPG-Home-Verzeichnis erstellen
sudo mkdir -p /var/lib/gpg-signer

# 2. GPG-Schlüsselstruktur dorthin kopieren (oder neu importieren)
sudo cp -r /var/lib/postfix/.gnupg /var/lib/gpg-signer/

# 3. Besitzer auf 'nobody' ändern (Debian-Gruppe: nogroup)
sudo chown -R nobody:nogroup /var/lib/gpg-signer/.gnupg

# 4. Strikt erforderliche GPG-Rechte setzen
sudo chmod 700 /var/lib/gpg-signer/.gnupg
sudo chmod 400 /var/lib/gpg-signer/.gnupg/.gpg_passphrase

[!warning] Wichtig für GPG Sollten die Rechte nicht exakt auf 700 (Ordner) und 400 (Passphrase) stehen, verweigert GPG die Entschlüsselung des privaten Schlüssels im Batch-Modus mit einem Permission Denied Fehler.

2. Postfix Konfiguration (/etc/postfix/master.cf)

Am Ende der /etc/postfix/master.cf wird der pipe-Dienst definiert. Zudem wird ein loopback-Port auf 10025 geöffnet, damit das Skript die signierte Mail ohne Filter-Endlosschleife wieder an Postfix übergeben kann.

PLAINTEXT
# ==========================================================================
# GPG Mail Signer Service
# ==========================================================================
gpg-signer unix - n n - - pipe
  flags=Rq user=nobody:nogroup argv=/usr/local/bin/gpg-sign.py ${sender} ${recipient}

127.0.0.1:10025 inet n - n - - smtpd
  -o content_filter=
  -o receive_override_options=no_unknown_recipient_checks,no_header_body_checks,no_milters

3. Python-Signatur-Skript (/usr/local/bin/gpg-sign.py)

Das Skript trennt die eingehende E-Mail in Header und Body, um zu verhindern, dass die Mail-Metadaten (wie Subject, To, From) mit-signiert werden, was den Mail-Header für Mailclients unlesbar machen würde. Danach wird die Mail via SMTP über Port 10025 re-injiziert.

PYTHON
#!/usr/bin/env python3
import os
import smtplib
import subprocess
import sys

GPG_BIN = "/usr/bin/gpg"
HOMEDIR = "/var/lib/gpg-signer/.gnupg"
KEY_ID = "D464EDA3A58C47C4"
PASS_FILE = "/var/lib/gpg-signer/.gnupg/.gpg_passphrase"

def sign_and_reinject():
    if len(sys.argv) < 3:
        sys.stderr.write("Error: Missing sender or recipient\n")
        sys.exit(1)

    sender = sys.argv[1]
    recipient = sys.argv[2]

    # Read full raw email from Postfix
    raw_email = sys.stdin.buffer.read()

    # Separate headers and body to only sign the body content
    if b"\r\n\r\n" in raw_email:
        headers, body = raw_email.split(b"\r\n\r\n", 1)
    elif b"\n\n" in raw_email:
        headers, body = raw_email.split(b"\n\n", 1)
    else:
        headers = b""
        body = raw_email

    env = os.environ.copy()
    env["GPG_TTY"] = "/dev/null"

    cmd = [
        GPG_BIN,
        "--homedir",
        HOMEDIR,
        "--batch",
        "--yes",
        "--no-tty",
        "--pinentry-mode",
        "loopback",
        "--passphrase-file",
        PASS_FILE,
        "--clearsign",
        "--local-user",
        KEY_ID,
    ]

    proc = subprocess.Popen(
        cmd,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        env=env,
    )

    signed_body, err = proc.communicate(input=body)

    if proc.returncode != 0:
        sys.stderr.write(f"GPG Error: {err.decode()}\n")
        sys.exit(1)

    # Normalize line endings to strict Unix LF first for processing
    headers_lf = headers.replace(b"\r\n", b"\n")

    # Ensure UTF-8 MIME headers if missing (crucial for German Umlauts)
    if b"MIME-Version:" not in headers_lf:
        headers_lf += b"\nMIME-Version: 1.0"
    if b"Content-Type:" not in headers_lf:
        headers_lf += b"\nContent-Type: text/plain; charset=utf-8"
    if b"Content-Transfer-Encoding:" not in headers_lf:
        headers_lf += b"\nContent-Transfer-Encoding: 8bit"

    # Convert everything to strict RFC-compliant CRLF (\r\n) for SMTP transmission
    clean_headers = headers_lf.replace(b"\n", b"\r\n")
    clean_body = signed_body.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")

    signed_email = clean_headers + b"\r\n\r\n" + clean_body

    # Re-inject the email back into Postfix via the loop-free port
    try:
        with smtplib.SMTP("127.0.0.1", 10025) as server:
            server.sendmail(sender, recipient, signed_email)
    except Exception as smtp_err:
        sys.stderr.write(f"SMTP Error: {smtp_err}\n")
        sys.exit(1)

if __name__ == "__main__":
    sign_and_reinject()

Rechte des Skripts anpassen:

Das Skript muss für den Benutzer nobody ausführbar sein:

BASH
sudo chown nobody:nogroup /usr/local/bin/gpg-sign.py
sudo chmod 755 /usr/local/bin/gpg-sign.py

4. Nützliche Befehle für den Betrieb

BASH
# Postfix Konfiguration nach Änderungen neu laden
sudo systemctl restart postfix

# E-Mail-Warteschlange (Queue) einsehen
mailq

# Postfix Queue sofort zwangsweise leeren (Zustellung erzwingen)
sudo postqueue -f

# Live-Logs der E-Mail-Zustellung überwachen
sudo tail -f /var/log/mail.log