GmCapsule [main]

Added Misfin-to-email forwarder extension; bumped version to 0.9

=> e88c8439b919809fafcbbbc1fd51198abea220c0

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__ = [
-    'Config', 'Cache', 'Context', 'Identity',
+    'Config', 'Cache', 'Context', 'GeminiError', 'Identity',
     '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
 
+    def receive_data(self, expected_size):
+        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 
+# 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):
+    h = hashlib.sha256()
+    h.update(crypto.dump_certificate(crypto.FILETYPE_ASN1, x509_cert))
+    return h.hexdigest()
+
+
+def parse_identity(cert) -> tuple:
+    """Returns: (mailbox, host, blurb)"""
+    host = None
+
+    comps = {}
+    for (comp, value) in cert.get_subject().get_components():
+        comps[comp.decode('utf-8')] = value.decode('utf-8')
+    i = 0
+    while i < cert.get_extension_count():
+        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
+    if not host:
+        raise Exception(f"{cert}: subject alternative name not specified")
+
+    return (comps['UID'], host, comps['CN'])
+
+
+
+class MisfinError (Exception):
+    def __init__(self, status, meta):
+        self.status = status
+        self.meta = meta
+
+    def __str__(self):
+        return f'{self.status} {self.meta}'
+
+
+class Recipient:
+    # Note: Generating a recipient certificate:
+    # openssl req -x509 -key misfin.key -outform PEM -out misfin.pem -sha256 -days 100000 -addext 'subjectAltName=DNS:example.com' -subj '/CN=blurb/UID=mailbox'
+
+    def __init__(self, name, cert, key, email):
+        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:
+    def __init__(self, context):
+        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
+
+    def check_sender(self, identity):
+        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
+
+    def __call__(self, request_data):
+        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):
+    if context.config().prefixed_sections('misfin.').values():
+        context.add_protocol('misfin', MisfinHandler(context))
Proxy Information
Original URL
gemini://git.skyjake.fi/gmcapsule/main/cdiff/e88c8439b919809fafcbbbc1fd51198abea220c0
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
31.188377 milliseconds
Gemini-to-HTML Time
0.676902 milliseconds

This content has been proxied by September (ba2dc).