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 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):

+ 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/pcdiff/e88c8439b919809fafcbbbc1fd51198abea220c0
Status Code
Success (20)
Meta
text/plain
Capsule Response Time
39.650578 milliseconds
Gemini-to-HTML Time
5.156283 milliseconds

This content has been proxied by September (ba2dc).