// SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "bufio" "bytes" "crypto/tls" "encoding/binary" "encoding/json" "flag" "fmt" "io" "log" "net" "net/url" "os/user" "strconv" "strings" "syscall" "time" ) import ( //#include //#include "C" ) type tlsConnectionInfo struct { TlsVersion uint16 `json:"tls_version"` CipherSuite uint16 `json:"cipher_suite"` SessionResumed bool `json:"session_resumed"` NegotiatedProtocol string `json:"alpn_negotiated_protocol"` // ALPN } type prefixConn struct { net.Conn io.Reader } func (c prefixConn) Read(p []byte) (int, error) { return c.Reader.Read(p) } const html = ` TLS Client Hello Mirror

TLS Client Hello Mirror

Your browser's TLS Client Hello, reflected as JSON

This test:

JSON only, for now, but a UI is on the roadmap.

The API is largely stable - fields may be added, but existing fields will not be modified or removed. IANA-assigned codes for TLS parameters and extensions are documented at:

Note that these lists do not include draft extensions and GREASE values. Missing values will be documented here as the project evolves.


` const gemtext = `# TLS Client Hello Mirror => /json/v1 Your browser's TLS Client Hello, reflected as JSON This test: * reflects the complete Client Hello message, preserving the order in which TLS parameters and extensions are sent; * can be used to check for TLS privacy pitfalls (session resumption, TLS fingerprinting, system time exposure); * supports multiple protocols (currently HTTP and Gemini); * is free as in freedom and trivial to self-host. JSON only, for now. The API is largely stable - fields may be added, but existing fields will not be modified or removed. IANA-assigned codes for TLS parameters and extensions are available at: => https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml TLS parameters => https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml TLS extensions Note that these lists do not include draft extensions and GREASE values. Missing values will be documented here as the project evolves. _____________________ => https://nervuri.net/ Author: nervuri => https://tildegit.org/nervuri/client-hello-mirror Source (contributions welcome) => https://www.gnu.org/licenses/agpl-3.0.en.html License: AGPL-3.0-or-later` func peek(conn net.Conn, tlsConfig *tls.Config) { // Copy the Client Hello before starting the TLS handshake. defer conn.Close() var buf bytes.Buffer _, err := io.CopyN(&buf, conn, 5) // TLS record header if err != nil { log.Println(err) return } length := binary.BigEndian.Uint16(buf.Bytes()[3:5]) _, err = io.CopyN(&buf, conn, int64(length)) if err != nil { log.Println(err) return } rawClientHello := buf.Bytes() pConn := prefixConn{ Conn: conn, Reader: io.MultiReader(&buf, conn), } server := tls.Server(pConn, tlsConfig) err = server.Handshake() if err != nil { log.Println(err) return } tlsHandler(server, rawClientHello) } func tlsHandler(conn *tls.Conn, rawClientHello []byte) { defer conn.Close() scanner := bufio.NewScanner(conn) scanner.Scan() line := scanner.Text() err := scanner.Err() if err != nil { log.Println(err) return } //log.Println(line) var protocol string var path string // requested page // Detect protocol by checking the first few characters. if strings.HasPrefix(line, "gemini://") { protocol = "Gemini" u, err := url.Parse(line) if err != nil { log.Println(err) return } path = u.Path if path == "" { path = "/" } if path == "/" { _, err = conn.Write([]byte("20 text/gemini\r\n")) } else if path == "/json/v1" { _, err = conn.Write([]byte("20 application/json\r\n")) } } else if strings.HasPrefix(line, "GET ") || strings.HasPrefix(line, "POST ") || strings.HasPrefix(line, "HEAD ") { protocol = "HTTP" path = strings.Split(line, " ")[1] if path == "/" { _, err = conn.Write([]byte("HTTP/1.1 200 OK\r\n")) _, err = conn.Write([]byte("Content-Type: text/html; charset=utf-8\r\n")) _, err = conn.Write([]byte("Cache-Control: no-store\r\n")) _, err = conn.Write([]byte("Vary: *\r\n")) } else if path == "/json/v1" { _, err = conn.Write([]byte("HTTP/1.1 200 OK\r\n")) _, err = conn.Write([]byte("Content-Type: application/json\r\n")) _, err = conn.Write([]byte("Cache-Control: no-store\r\n")) _, err = conn.Write([]byte("Vary: *\r\n")) } else { _, err = conn.Write([]byte("HTTP/1.1 404 Not Found\r\n")) } _, err = conn.Write([]byte("\r\n")) // end headers if strings.HasPrefix(line, "HEAD ") { return // headers only } } else { log.Println("Unknown protocol") return } var clientHello clientHelloMsg if !clientHello.unmarshal(rawClientHello) { log.Println("Failed to parse Client Hello") return } connectionState := conn.ConnectionState() // TLS tlsConnInfo := tlsConnectionInfo{ connectionState.Version, connectionState.CipherSuite, connectionState.DidResume, connectionState.NegotiatedProtocol, } if path == "/" { if protocol == "HTTP" { _, err = conn.Write([]byte(html)) } else if protocol == "Gemini" { _, err = conn.Write([]byte(gemtext)) } else { log.Println("Unknown protocol") return } } else if path == "/json/v1" { output := struct { ClientHello clientHelloMsg `json:"client_hello"` TlsConnectionInfo tlsConnectionInfo `json:"connection_info"` }{ clientHello, tlsConnInfo, } outputJSON, err := json.MarshalIndent(output, "", " ") if err != nil { log.Println(err) return } _, err = conn.Write(outputJSON) } if err != nil { log.Println(err) return } } func main() { var certFile, keyFile string var userToSwitchTo string var hostAndPort string // Parse arguments flag.StringVar(&certFile, "c", "", "path to certificate file") flag.StringVar(&keyFile, "k", "", "path to private key file") flag.StringVar(&userToSwitchTo, "u", "www-data", "user to switch to, if running as root") flag.Parse() hostAndPort = flag.Arg(0) if certFile == "" || keyFile == "" || hostAndPort == "" { fmt.Println("usage: client-hello-mirror -c cert.pem -k key.pem host:port") return } // Load cert cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { log.Fatal(err) return } // TLS config tlsConfig := tls.Config{ Certificates: []tls.Certificate{cert}, //MaxVersion: tls.VersionTLS12, } // Listen for connections ln, err := net.Listen("tcp", hostAndPort) if err != nil { log.Println(err) return } defer ln.Close() // Drop root if syscall.Getuid() == 0 { if userToSwitchTo == "" { fmt.Println("Running as root. Please specify an unprivileged user to switch to, using the -u flag") return } userInfo, err := user.Lookup(userToSwitchTo) if err != nil { fmt.Println(err) if userToSwitchTo == "www-data" { fmt.Println("Running as root. Please specify an unprivileged user to switch to, using the -u flag") } return } uid, err := strconv.ParseInt(userInfo.Uid, 10, 32) if err != nil { fmt.Println(err) return } gid, err := strconv.ParseInt(userInfo.Gid, 10, 32) if err != nil { fmt.Println(err) return } cerr, errno := C.setgid(C.__gid_t(gid)) if cerr != 0 { log.Fatalln("Unable to set GID due to error:", errno) } cerr, errno = C.setuid(C.__uid_t(uid)) if cerr != 0 { log.Fatalln("Unable to set UID due to error:", errno) } } log.Println("Server started") for { conn, err := ln.Accept() if err != nil { log.Println("Error accepting: ", err.Error()) continue } conn.SetReadDeadline(time.Now().Add(5 * time.Second)) // timeout go peek(conn, &tlsConfig) } }