Files

158 lines
6.6 KiB
Python
Executable File

#!/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) <kryštof@pešek.com>"
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()