// 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://@ // "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 if line[len(line)-2:] == "\r\n" { body = body[:len(body)-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) } }