💾 Archived View for thrig.me › blog › 2023 › 05 › 10 › misfind.go captured on 2024-06-16 at 13:32:51.

View Raw

More Information

⬅️ Previous capture (2023-09-08)

-=-=-=-=-=-=-

// misfind - a prototype misfin server written in Go. if you are not on
// OpenBSD, then the optional "protect" lines can be removed should you
// not want to deal with the "sauh.dev/protect" import. Usage:
//
//   go build misfind.go
//
//   openssl req -x509 -newkey rsa:2048 -keyout usercert.pem -out usercert.pem -sha256 -days 8192 -nodes -subj "/CN=Ed Gruberman/UID=ed" -addext "subjectAltName = DNS:example.org"
//
//   mkdir some-directory-for-uploads
//   ./misfind 1958 usercert.pem some-directory-for-uploads
//
// a test certificate could use localhost for the DNS, and then connect
// to the loopback interface. try this out before running the server on
// the internet?
//
// for more information on misfin, see:
//
//   gemini://misfin.org/

package main

import (
	"bufio"
	"bytes"
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"io"
	"log"
	"log/syslog"
	"os"
	"time"
	// optional, adds pledge on OpenBSD
	"suah.dev/protect"
)

// this assumes that the client certificate has CN/UID/DNS fields
type Request struct {
	name     string
	userid   string
	hostname string
	messsage string
}

// see the amfora client/tofu.go code for where this comes from and how
// a fingerprint on the SPI is perhaps better than one on the whole
// certificate: the SPI will not change if the private key is reused
// across multiple certificates
func fingerprint(cert *x509.Certificate) string {
	h := sha256.New()
	h.Write(cert.RawSubjectPublicKeyInfo)
	return fmt.Sprintf("%X", h.Sum(nil))
}

func handle_connection(conn *tls.Conn, syslog *syslog.Writer, fingerprint string) {
	// TODO how to send close-notify? misfin software MUST do so.
	// need to dump a transaction to see what golang does by default
	defer conn.Close()

	syslog.Warning(fmt.Sprintf("connect %s \"%s\" %s@%s", conn.RemoteAddr().String()))

	if err := conn.Handshake(); err != nil {
		syslog.Warning(fmt.Sprintf("handshake failed '%s': %s", conn.RemoteAddr().String(), err.Error()))
		return
	}

	var request Request

	now := time.Now()

	peer_certs := conn.ConnectionState().PeerCertificates
	if len(peer_certs) > 0 {
		cert := peer_certs[0]
		if now.After(cert.NotAfter) {
			conn.Write([]byte("62 certificate has expired\r\n"))
			return
		}
		if now.Before(cert.NotBefore) {
			conn.Write([]byte("62 certificate not yet valid\r\n"))
			return
		}

		request.name = cert.Subject.CommonName

		// TODO or do we fail if there are multiple names? this
		// picks the first one
		if len(cert.DNSNames) > 0 {
			request.hostname = cert.DNSNames[0]
		} else {
			conn.Write([]byte("62 certificate lacks DNS entry\r\n"))
			return
		}

		// this complication by way of
		// https://stackoverflow.com/questions/39125873/golang-subject-dn-from-x509-cert
		var oidUserID = []int{0, 9, 2342, 19200300, 100, 1, 1}
		for _, n := range cert.Subject.Names {
			if n.Type.Equal(oidUserID) {
				if v, ok := n.Value.(string); ok {
					request.userid = v
				}
			}
		}

		// only accept certificates we can (in theory) reply to
		if len(request.userid) == 0 {
			conn.Write([]byte("62 certificate lacks userid\r\n"))
			return
		}
		if len(request.hostname) == 0 {
			conn.Write([]byte("62 certificate lacks hostname\r\n"))
			return
		}
	} else {
		// TODO can this ever happen given the ClientAuth configuration?
		conn.Write([]byte("60 certificate required\r\n"))
		return
	}

	// NOTE probably want to log the client IP somewhere so that
	// problematic IP addresses can be banned (it's also in the
	// written message, below)
	// maybe also limit things in the firewall (max connections,
	// etc) and put a quota on the user so the daemon cannot fill up
	// a partition?
	//syslog.Warning(fmt.Sprintf("client %s \"%s\" %s@%s", conn.RemoteAddr().String(), request.name, request.userid, request.hostname))

	// do not let the connection linger for too long. downside: user
	// cannot take their time manually typing into nc(1) or they are
	// on a 300 baud line from Mars and there's a bit too much
	// latency... something like that
	conn.SetReadDeadline(time.Now().Add(time.Second * 30))
	conn.SetWriteDeadline(time.Now().Add(time.Second * 5))

	// format (section 1.2)
	//   misfin://<MAILBOX>@<HOSTNAME><SPACE><MESSAGE><CR><LF>
	// "and the entire request should not exceed 2048 bytes"
	rlim := io.LimitReader(conn, 2048)

	prefix := make([]byte, 9)
	_, err := io.ReadFull(rlim, prefix)
	if err != nil {
		conn.Write([]byte("59 read prefix failed\r\n"))
		return
	}
	if !bytes.Equal(prefix, []byte("misfin://")) {
		conn.Write([]byte("59 missing misfin://\r\n"))
		return
	}

	reader := bufio.NewReader(rlim)
	mailbox, err := reader.ReadString('@')
	if err != nil {
		conn.Write([]byte("59 could not read mailbox\r\n"))
		return
	}

	hostname, err := reader.ReadString(' ')
	if err != nil {
		conn.Write([]byte("59 could not read hostname\r\n"))
		return
	}

	// the message ends with \r\n but might contain standalone \r or
	// \n in it, which complicates the parsing (or there may be a
	// better way in Go that I don't know about?)
	var body string
	for {
		line, err := reader.ReadString('\n')
		if err != nil {
			// we might have an incomplete line here, so
			// could save a partial or malformed message,
			// but let's go for a more strict implementation
			conn.Write([]byte("59 read message failed\r\n"))
			return
		}
		body += line
		amount := len(body)
		if amount > 1 && body[amount-2:] == "\r\n" {
			body = body[:amount-2]
			break
		}
	}

	fh, err := os.CreateTemp(".", "misfin.msg.")
	if err != nil {
		syslog.Warning(fmt.Sprintf("CreateTemp failed: %s", err.Error()))
		conn.Write([]byte(fmt.Sprintf("42 write failure\r\n")))
		return
	}
	// TWEAK maybe you want a different output format? this is so I
	// can integrate misfin into my existing maildir/mutt workflow
	fmt.Fprintf(fh, "From: \"%s\" <%s@%s>\nDate: %s\n\nsource\tmisfin://%s@%s [%s]\ndest\tmisfin://%s@%s\n\n%s\n", request.name, request.userid, request.hostname, now.Format(time.RFC1123Z), request.userid, request.hostname, conn.RemoteAddr().String(), mailbox, hostname, body)
	fherr := fh.Close()
	if fherr != nil {
		syslog.Warning(fmt.Sprintf("close failed: %s", fherr.Error()))
		conn.Write([]byte(fmt.Sprintf("42 write failure\r\n")))
	}

	// section 1.3 response: fingerprint of our server certificate,
	// which is the same certificate used for sending messages with
	conn.Write([]byte(fmt.Sprintf("20 %s\r\n", fingerprint)))
}

// KLUGE tls.LoadX509KeyPair wipes the .Leaf or I'm missing something so
// there's no easy way to get the fingerprint of our certificate.
// therefore we load the key-and-certificate file manually, and ...
func load_keypair(file string) (tls.Certificate, string) {
	buf, err := os.ReadFile(file)
	if err != nil {
		fmt.Fprintf(os.Stderr, "misfind: could not read '%s': %s\n", file, err.Error())
		os.Exit(1)
	}

	// KLUGE key is assumed to come first in the *.pem file
	_, rest := pem.Decode(buf)
	buf_cert, _ := pem.Decode(rest)
	cert, cerr := x509.ParseCertificate(buf_cert.Bytes)
	if cerr != nil {
		fmt.Fprintf(os.Stderr, "misfind: could not parse certificate '%s': %s\n", file, cerr.Error())
		os.Exit(1)
	}

	tls_cert, xerr := tls.X509KeyPair(rest, buf)
	if xerr != nil {
		fmt.Fprintf(os.Stderr, "misfind: X509KeyPair failed '%s': %s\n", file, xerr.Error())
		os.Exit(1)
	}

	return tls_cert, fingerprint(cert)
}

func main() {
	if len(os.Args) != 4 {
		fmt.Fprintln(os.Stderr, "Usage: misfind port certificate-and-key.pem working-directory")
		os.Exit(64)
	}

	// assumes a *.pem file that contains both key and certificate,
	// which is what the misfin reference implementation
	// make-cert.sh does
	server_cert, fprint := load_keypair(os.Args[2])

	ssl_config := &tls.Config{
		// misfin specification calls for a floor of TLS 1.2
		// (section 3, TLS)
		MinVersion: tls.VersionTLS12,
		// better crypto at the cost of reduced portability
		// according to some random webpage I found. it might
		// have been targetting ssllabs test passing
		CurvePreferences:         []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256},
		PreferServerCipherSuites: true,
		CipherSuites: []uint16{
			tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
			tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
			tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
			tls.TLS_RSA_WITH_AES_256_CBC_SHA,
		},

		Certificates: []tls.Certificate{server_cert},
		// NOTE this server requires a particular client
		// certificate; the specification indicates that a
		// server might accept anonymous certificates
		ClientAuth:         tls.RequireAnyClientCert,
		InsecureSkipVerify: true,
	}

	syslog, err := syslog.New(syslog.LOG_DAEMON|syslog.LOG_WARNING, "misfind")
	if err != nil {
		log.Fatal(err)
	}

	port := ":" + os.Args[1]
	l, err := tls.Listen("tcp", port, ssl_config)
	if err != nil {
		log.Fatal(err)
	}
	defer l.Close()

	herr := os.Chdir(os.Args[3])
	if herr != nil {
		log.Fatal(herr)
	}

	// optional, limit system calls and filesystem access on OpenBSD
	protect.Pledge("cpath inet rpath stdio unveil wpath")
	protect.Unveil(".", "crw")
	protect.UnveilBlock()

	for {
		c, err := l.Accept()
		if err != nil {
			syslog.Warning(fmt.Sprintf("accept failed: %s", err.Error()))
			continue
		}
		ssl_c, ok := c.(*tls.Conn)
		if !ok {
			syslog.Warning("no TLS connection found??")
			c.Close()
			continue
		}
		// NOTE this server only supports a single mailbox (or
		// rather merges all messages into files in a single
		// directory); a multi-host server would need to get the
		// certificate for the mailbox and return the
		// fingerprint of that, blah blah blah
		go handle_connection(ssl_c, syslog, fprint)
	}
}