diff --git a/pgp-mail b/pgp-mail new file mode 100755 index 0000000..8305755 --- /dev/null +++ b/pgp-mail @@ -0,0 +1,157 @@ +#!/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()