#!/usr/bin/env python3 """ ~/bin/pgp-mail — Universal RFC 3156 PGP/MIME & Inline Signed Email Dispatcher Fleet utility for zero-bloat, cypherpunk cryptographic email signing. """ import os import sys import argparse import subprocess import mimetypes from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders DEFAULT_KEY = "369D0E68D252C6C43B4997E3CBE1784B83F6BA04" DEFAULT_FROM = "Kryštof Pešek (Kof) " def canonicalize_crlf(data: bytes) -> bytes: # Ensure all newlines are strictly CRLF per RFC 3156 / RFC 2046 lines = data.splitlines() return b"\r\n".join(lines) + b"\r\n" def gpg_detach_sign(payload: bytes, key_id: str) -> bytes: cmd = ["gpg", "--batch", "--armor", "--detach-sign", "-u", key_id] proc = subprocess.run(cmd, input=payload, capture_output=True) if proc.returncode != 0: raise RuntimeError(f"GPG detached signing failed: {proc.stderr.decode('utf-8', errors='ignore')}") return proc.stdout def gpg_clearsign(text: str, key_id: str) -> str: cmd = ["gpg", "--batch", "--armor", "--clearsign", "-u", key_id] proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True) if proc.returncode != 0: raise RuntimeError(f"GPG clearsign failed: {proc.stderr.decode('utf-8', errors='ignore')}") return proc.stdout.decode("utf-8", errors="ignore") def build_pgp_mime_message(from_addr: str, to_addr: str, subject: str, body_text: str, attachments: list, key_id: str) -> bytes: import uuid boundary = f"===============pgp_{uuid.uuid4().hex[:16]}==" # Inner content container if attachments: inner = MIMEMultipart("mixed") inner.attach(MIMEText(body_text, "plain", "utf-8")) for fpath in attachments: ctype, _ = mimetypes.guess_type(fpath) maintype, subtype = (ctype.split("/", 1)) if ctype else ("application", "octet-stream") with open(fpath, "rb") as fp: part = MIMEBase(maintype, subtype) part.set_payload(fp.read()) encoders.encode_base64(part) part.add_header("Content-Disposition", f'attachment; filename="{os.path.basename(fpath)}"') inner.attach(part) content_bytes = inner.as_bytes() else: text_part = MIMEText(body_text, "plain", "utf-8") content_bytes = text_part.as_bytes() canonical_content = canonicalize_crlf(content_bytes) # Detached signature sig_bytes = gpg_detach_sign(canonical_content, key_id) canonical_sig = canonicalize_crlf(sig_bytes) # Reconstruct exact RFC 3156 multipart/signed body_payload = ( f"--{boundary}\r\n".encode("ascii") + canonical_content + f"\r\n--{boundary}\r\n".encode("ascii") + b"Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n" + b"Content-Description: OpenPGP Digital Signature\r\n" + b"Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n" + canonical_sig + f"\r\n--{boundary}--\r\n".encode("ascii") ) header_lines = [ f"From: {from_addr}", f"To: {to_addr}", f"Subject: {subject}", f"MIME-Version: 1.0", f"X-PGP-Agent: Antigravity/Fleet-OpenPGP", f'Content-Type: multipart/signed; boundary="{boundary}"; micalg=pgp-sha512; protocol="application/pgp-signature"' ] header_bytes = "\r\n".join(header_lines).encode("utf-8") + b"\r\n\r\n" return header_bytes + body_payload def build_inline_message(from_addr: str, to_addr: str, subject: str, body_text: str, key_id: str) -> bytes: signed_text = gpg_clearsign(body_text, key_id) msg = MIMEText(signed_text, "plain", "utf-8") msg["From"] = from_addr msg["To"] = to_addr msg["Subject"] = subject msg["X-PGP-Signature"] = "OpenPGP Inline Clearsign" return msg.as_bytes() def find_mta(): for bin_path in ["/usr/bin/msmtp", "/usr/local/bin/msmtp", "/usr/sbin/sendmail", "/usr/lib/sendmail"]: if os.path.exists(bin_path) and os.access(bin_path, os.X_OK): return bin_path return None def main(): parser = argparse.ArgumentParser(description="Send PGP/MIME or Inline Signed Emails") parser.add_argument("to", help="Recipient email address") parser.add_argument("-s", "--subject", default="(bez předmětu)", help="Email subject") parser.add_argument("-f", "--from-addr", default=DEFAULT_FROM, help=f"From header (default: {DEFAULT_FROM})") parser.add_argument("-m", "--message", help="Message body (reads from stdin if omitted)") parser.add_argument("-a", "--attach", action="append", default=[], help="File to attach (repeatable)") parser.add_argument("-k", "--key", default=DEFAULT_KEY, help=f"GPG key ID/Fingerprint (default: {DEFAULT_KEY})") parser.add_argument("--inline", action="store_true", help="Use inline ASCII clearsign instead of PGP/MIME") parser.add_argument("--dry-run", action="store_true", help="Print raw email to stdout without dispatching") parser.add_argument("--mta", help="Custom MTA command line") args = parser.parse_args() # Read body if args.message: body = args.message else: if sys.stdin.isatty(): print("[*] Enter message body (terminate with Ctrl+D):", file=sys.stderr) body = sys.stdin.read() # Build signed message if args.inline: if args.attach: print("[-] Error: --inline does not support attachments. Use standard PGP/MIME.", file=sys.stderr) sys.exit(1) raw_msg = build_inline_message(args.from_addr, args.to, args.subject, body, args.key) else: raw_msg = build_pgp_mime_message(args.from_addr, args.to, args.subject, body, args.attach, args.key) if args.dry_run: sys.stdout.buffer.write(raw_msg) return # Dispatch mta = args.mta or find_mta() if not mta: print("[-] Error: No MTA found (checked msmtp, sendmail). Use --dry-run or install msmtp/postfix.", file=sys.stderr) sys.exit(1) print(f"[*] Dispatching PGP-signed email to {args.to} via {mta} (Key: {args.key[:16]}...)...") mta_cmd = [mta, "-t"] if "msmtp" in mta else [mta, "-i", "-t"] proc = subprocess.run(mta_cmd, input=raw_msg, capture_output=True) if proc.returncode == 0: print(f"[✓] Email cryptographically signed and dispatched successfully.") else: print(f"[-] MTA error (code {proc.returncode}): {proc.stderr.decode('utf-8', errors='ignore')}", file=sys.stderr) sys.exit(proc.returncode) if __name__ == "__main__": main()