From e88c8439b919809fafcbbbc1fd51198abea220c0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jaakko=20Kera=CC=88nen?= jaakko.keranen@iki.fi
Date: Sat, 24 Aug 2024 11:40:12 +0300
Subject: [PATCH 1/1] Added Misfin-to-email forwarder extension; bumped version
to 0.9
The Misfin module implements reception of B and C variants of the
protocol, sending the messages to the configured email address if
certificates pass validity and TOFU checks.
gmcapsule/init.py | 6 +-
gmcapsule/gemini.py | 30 +++--
gmcapsule/modules/80_misfin.py | 215 +++++++++++++++++++++++++++++++++
3 files changed, 238 insertions(+), 13 deletions(-)
create mode 100644 gmcapsule/modules/80_misfin.py
diff --git a/gmcapsule/init.py b/gmcapsule/init.py
index a16c800..27e3a2f 100644
--- a/gmcapsule/init.py
+++ b/gmcapsule/init.py
@@ -502,13 +502,13 @@ import shlex
import subprocess
from pathlib import Path
-from .gemini import Server, Cache, Context, Identity
+from .gemini import Server, Cache, Context, Identity, GeminiError
from .markdown import to_gemtext as markdown_to_gemtext
-version = '0.8.0'
+version = '0.9.0'
all = [
'get_mime_type', 'markdown_to_gemtext'
]
diff --git a/gmcapsule/gemini.py b/gmcapsule/gemini.py
index 329c641..c5cea90 100644
--- a/gmcapsule/gemini.py
+++ b/gmcapsule/gemini.py
@@ -376,14 +376,10 @@ def handle_gemini_or_titan_request(request_data):
if expected_size > max_upload_size and max_upload_size > 0:
report_error(stream, 59, "Maximum content length exceeded")
return
while len(data) < expected_size:
incoming = safe_recv(stream, 65536)
if len(incoming) == 0:
break
data += incoming
if len(data) != expected_size:
if not request_data.receive_data(expected_size):
report_error(stream, 59, "Invalid content length")
return
data = request_data.buffered_data
else:
# Edit requests do not contain data.
if len(data):
@@ -720,6 +716,16 @@ class RequestData:
self.identity = identity
self.request = request
while len(self.buffered_data) < expected_size:
incoming = safe_recv(self.stream, 65536)
if len(incoming) == 0:
break
self.buffered_data += incoming
if len(self.buffered_data) != expected_size:
return False
return True
class RequestParser(threading.Thread):
"""Thread that parses incoming requests from clients."""
@@ -763,11 +769,15 @@ class RequestParser(threading.Thread):
def process_request(self, stream, from_addr):
data = bytes()
MAX_LEN = 1024 # TODO: Gemini/Titan limitation only.
MAX_LEN = 1024 # applies only to Gemini and Titan
MAX_RECV = MAX_LEN + 2 # includes terminator "\r\n"
MAX_RECV_ANY_PROTOCOL = 65536
request = None
incoming = safe_recv(stream, MAX_RECV)
def is_gemini_based(data):
return data.startswith(b'gemini:') or data.startswith(b'titan:')
try:
while len(data) < MAX_RECV:
data += incoming
@@ -776,12 +786,11 @@ class RequestParser(threading.Thread):
request = data[:crlf_pos].decode('utf-8')
data = data[crlf_pos + 2:]
break
elif len(data) > MAX_LEN:
elif len(data) > MAX_LEN and is_gemini_based(data):
# At this point we should have received the line terminator.
self.log(from_addr, 'sent a malformed request')
report_error(stream, 59, "Request exceeds maximum length")
return
incoming = safe_recv(stream, MAX_RECV - len(data))
if len(incoming) <= 0:
break
@@ -789,7 +798,8 @@ class RequestParser(threading.Thread):
report_error(stream, 59, "Request contains malformed UTF-8")
return
if not request or len(data) > MAX_RECV:
if not request or (len(data) > MAX_RECV and is_gemini_based(data)) or \
len(data) > MAX_RECV_ANY_PROTOCOL:
report_error(stream, 59, "Bad request")
return
diff --git a/gmcapsule/modules/80_misfin.py b/gmcapsule/modules/80_misfin.py
new file mode 100644
index 0000000..626981e
--- /dev/null
+++ b/gmcapsule/modules/80_misfin.py
@@ -0,0 +1,215 @@
+# Copyright (c) 2024 Jaakko Keränen jaakko.keranen@iki.fi
+# License: BSD-2-Clause
+"""Misfin Email Bridge"""
+import gmcapsule
+import hashlib
+import re
+import subprocess
+from pathlib import Path
+from OpenSSL import SSL, crypto
+def get_fingerprint(x509_cert):
+def parse_identity(cert) -> tuple:
comps[comp.decode('utf-8')] = value.decode('utf-8')
ext = cert.get_extension(i)
if ext.get_short_name() == b'subjectAltName':
host = str(ext)
if not host.startswith('DNS:'):
raise Exception(f"{cert}: subject alternative name must specify a DNS hostname")
host = host[4:]
break
i += 1
raise Exception(f"{cert}: subject alternative name not specified")
+class MisfinError (Exception):
self.status = status
self.meta = meta
return f'{self.status} {self.meta}'
+class Recipient:
self.name = name
self.email = email
if not cert:
raise Exception(self.name + ': recipient certificate not specified')
if not key:
raise Exception(self.name + ': recipient private key not specified')
self.cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(cert, 'rb').read())
self.key = crypto.load_privatekey(crypto.FILETYPE_PEM, open(key, 'rb').read())
if not self.cert:
raise Exception(cert + ": invalid certificate")
if not self.key:
raise Exception(key + ": invalid private key")
self.fingerprint = get_fingerprint(self.cert)
crt_uid, crt_host, crt_blurb = parse_identity(self.cert)
if crt_uid != self.name:
raise Exception(f"{cert}: certificate user ID must match mailbox name ({name})")
self.host = crt_host
self.blurb = crt_blurb
+class MisfinHandler:
cfg = context.config().section('misfin')
self.context = context
self.email_cmd = cfg.get('email.cmd', fallback='/usr/sbin/sendmail')
self.email_from = cfg.get('email.from', fallback=None)
self.trust_file = Path.home() / '.misfin-known-senders'
self.reject = cfg.get('reject', fallback='').split()
self.always_trust = set()
self.recipients = {}
# Configure.
cfg = context.config()
for section in cfg.prefixed_sections('misfin.').values():
name = section.name[7:]
cert = section.get('cert', fallback=None)
key = section.get('key', fallback=None)
email = section.get('email', fallback=None)
if not email:
raise Exception("Misfin recipients must have an email forwarding address")
recp = Recipient(name, cert, key, email)
self.always_trust.add(recp.fingerprint)
self.recipients[name] = recp
if not identity:
raise MisfinError(60, 'Certificate required')
fp = identity.fp_cert
uid, host, blurb = parse_identity(crypto.load_certificate(crypto.FILETYPE_ASN1,
identity.cert))
addr = uid + '@' + host
if fp in self.always_trust:
return True
if fp in self.reject:
return False
if re.search(r'\s', uid):
raise MisfinError(62, 'Invalid sender mailbox')
if re.search(r'\s', host):
raise MisfinError(62, 'Invalid sender hostname')
try:
for line in open(self.trust_file, 'rt').readlines():
m = re.match(r'([0-9a-f]{64}) ([^\s]+) .*', line)
if m:
if m[1] == fp:
return True
if m[2] == addr:
if m[1] != fp:
raise MisfinError(63, 'Certificate does not match known identity')
return True
except FileNotFoundError:
pass
# Never seen this before, TOFU it.
print(f"{fp} {addr} {blurb}", file=open(self.trust_file, 'at'))
return True
resp_status = 20
resp_meta = ''
sender = request_data.identity
request = request_data.request
try:
# If we've seen this before, check that the fingerprint is the same.
if not self.check_sender(sender):
raise MisfinError(61, 'Unauthorized sender')
# Parse the request.
version = 'B'
m = re.match(r'misfin://([^@]+)@([^@\s]+) (.*)', request)
if m:
message = m[3]
else:
m = re.match(r'misfin://([^@]+)@([^@\s]+)\t(\d+)', request)
if not m:
raise MisfinError(59, "Bad request")
if not request_data.receive_data(int(m[3])):
raise MisfinError(59, 'Invalid content length')
version = 'C'
message = request_data.buffered_data.decode('utf-8')
mailbox = m[1]
host = m[2]
if mailbox not in self.recipients:
raise MisfinError(51, 'Mailbox not found')
recp = self.recipients[mailbox]
if host != recp.host:
raise MisfinError(53, 'Domain not serviced')
resp_meta = recp.fingerprint
# Forward as email.
try:
uid, host, blurb = parse_identity(crypto.load_certificate(crypto.FILETYPE_ASN1,
sender.cert))
subject = f"[misfin] Message from {uid}@{host}"
msg = f'From: {self.email_from}\n' + \
f'To: {recp.email}\n' + \
f'Subject: {subject}\n\n' + \
message.rstrip() + "\n\n" + \
f"=> misfin://{uid}@{host} {blurb}\n"
args = [self.email_cmd, '-i', recp.email]
if self.email_cmd == 'stdout':
print(args, msg)
else:
subprocess.check_output(args, input=msg, encoding='utf-8')
except Exception as x:
print('Error sending email:', x)
raise MisfinError(42, 'Internal error')
except MisfinError as er:
resp_status = er.status
resp_meta = er.meta
return f'{resp_status} {resp_meta}\r\n'.encode('utf-8')
+def init(context):
context.add_protocol('misfin', MisfinHandler(context))
--
2.25.1
text/plain
This content has been proxied by September (ba2dc).