// 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)
}
}
Response:
20 (Success), text/plain