Authentisch gesichert

Zwar gibt es keinen Grund, an der Authentizität meiner Backup-Benachrichtigungen zu zweifeln, aber nach der Wiedereingliederung von OpenPGP in den Mailstack möchte ich natürlich auch skriptbasierte E-Mails signieren. Um wohlgeformte E-Mails des MIME-Typs multipart/signed zu versenden, beschäftige ich mich noch einmal sehr intensiv mit den passenden Python-Modulen, akzeptiere nach einigen erfolglosen Bemühungen, dass das aktuelle API fehlerhaft/ungeeignet ist und verwende das legacy API auf Basis eines 10 Jahre alten Code-Schnipsels. Die Schmach wird etwas gemildert durch das Eingeständnis der Python-Dokumentation, dass mindestens email.mime nicht völlig überholt ist:

This module is part of the legacy (Compat32) email API. Its functionality is partially replaced by the contentmanager in the new API, but in certain applications these classes may still be useful, even in non-legacy code.

Das Versandskript benötigt neben diversen email-Modulen natürlich python-gnupg:

#!/usr/bin/env python3 import os import datetime import __main__ as main import smtplib from email.message import Message from email.mime.multipart import MIMEMultipart from email import charset from email.utils import make_msgid import keyring import gnupg smtp_server = 'mail.eden.one' smtp_user = 'user@eden.one' port = 587 localhost = 'client.eden.one' pgp_entry = 'PGP' pgp_user = pgp_entry def send_message(subject, body): now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') subject = f'{os.path.basename(main.__file__)}: {subject}' body = f'{body}\n\n{now}' basemsg = Message() basemsg.set_payload(body, charset='utf-8')

Ohne den Parameter charset an dieser Stelle besteht basemsg nur aus dem Inhalt der Variablen body, die Angabe eines character sets erzeugt dagegen ein vollwertiges MIME-Objekt:

MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64 VGV4dCBtaXQgw5xtbMOkdXRlbi4=

Nun ist es so, dass mutt (u.a. im Zusammenhang mit dem Parameter pgp_strict_enc zur obligatorischen Kodierung von Nachrichten mit trailing whitespace), der RFC 2045 für Text –

The Quoted-Printable encoding is intended to represent data that largely consists of octets that correspond to printable characters in the US-ASCII character set. [...] The Base64 Content-Transfer-Encoding is designed to represent arbitrary sequences of octets in a form that need not be humanly readable.

Rspamd sowie der RFC 2015 (etwas weniger deutlich sein Nachfolger RFC 3156) –

Though not required, it is generally a good idea to use Quoted-Printable encoding in the first step (writing out the data to be signed in MIME canonical format) if any of the lines in the data begin with "From ", and encode the "F". This will avoid an MTA inserting a ">" in front of the line, thus invalidating the signature!

– die platzsparende Kodierung quoted-printable bevorzugen. Um mich nicht des verschwenderischen Umgangs mit Speicherplatz und Bandbreite bezichtigen lassen zu müssen, erweitere ich das Skript um ein entsprechendes Charset()-Objekt und den charset-Parameter

base_charset = charset.Charset('utf-8') base_charset.body_encoding = charset.QP basemsg = Message() basemsg.set_payload(body, charset=base_charset)

– und erhalte:

MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: quoted-printable Text mit =C3=9Cml=C3=A4uten.

Dasselbe Ergebnis erzielt man übrigens auch, wenn man basemsg das Charset()-Objekt nicht im Rahmen von set_payload(), sondern nachträglich zuweist (basemsg.set_charset = base_charset). Eine vorzeitige Zuweisung führt dagegen wieder zu einer base64-Kodierung, weil set_payload() den Effekt von set_charset überschreibt (anders als bei MIMEText()-Objekten, die mit vorzeitigen replace_header()- und set_charset()-Anweisungen offenbar besser umgehen können). Weiter im Skript:

basetext = basemsg.as_string().replace('\n', '\r\n')

Hier sind sich RFC 2015 und RFC 3156 einig:

The data to be signed MUST first be converted to its content-type specific canonical form. For text/plain, this means conversion to an appropriate character set and conversion of line endings to the canonical <CR><LF> sequence.

Nun muss eine Signatur erzeugt und in ein Message()-Objekt umgewandelt werden:

gpg = gnupg.GPG(gpgbinary='/opt/homebrew/bin/gpg') pgp_passphrase = keyring.get_password(pgp_entry, pgp_user) signature = str(gpg.sign(basetext, keyid='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', passphrase=pgp_passphrase, detach=True)) signmsg = Message() signmsg['Content-Type'] = 'application/pgp-signature; name="signature.asc"' signmsg['Content-Description'] = 'OpenPGP digital signature' signmsg.set_payload(signature)

Der Parameter gpgbinary für gnupg.PGP() ist bei einem Aufruf des Skriptes über eine Shell mit wohlgefülltem $PATH tückischerweise entbehrlich, aber spätestens bei der Einbettung via launchctl zwingend erforderlich. Schließlich werden die beiden Message()-Objekte in einem MIMEMultipart()-Objekt zusammengeführt:

msg = MIMEMultipart(_subtype="signed", micalg="pgp-sha512", protocol="application/pgp-signature") msg.attach(basemsg) msg.attach(signmsg) msg['From'] = 'Sender <sender@eden.one>' msg['To'] = 'Recipient <recipient@eden.one>' msg['Subject'] = subject msg['Message-ID'] = make_msgid(domain=localhost) smtp_password = keyring.get_password(smtp_server, smtp_user) s = smtplib.SMTP(host=smtp_server, local_hostname=localhost, port=port) s.starttls() s.login(smtp_user, smtp_password) s.send_message(msg) del msg

Der Datenverkehr wächst durch die Signatur und den MIME-Overhead je Nachricht (dank quoted-printable nur) um rund 600 Bytes, aber im Gegenzug gewährt Rspamd ein zusätzliches Symbol (SIGNED_PGP (-2)). Und ich kann sicher sein, dass mir niemand ein erfolgreich durchgeführtes Backup vorgaukelt.