// SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "bufio" "bytes" "crypto/tls" "encoding/binary" "encoding/json" "flag" "io" "log" "net" "net/url" "os" "strings" "time" ) 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) } // Output to stderr and exit with error code 1. // Like log.Fatal, but without the date&time prefix. // Used before starting the server loop. func fatalError(err ...any) { logger := log.New(os.Stderr, "", 0) logger.Fatal(err...) } 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` // Copy the Client Hello message before starting the TLS handshake. func peek(conn net.Conn, tlsConfig *tls.Config) { defer conn.Close() var buf bytes.Buffer // Copy TLS record header. _, err := io.CopyN(&buf, conn, 5) if err != nil { log.Println(err) return } // Check if this is a TLS handshake record. if buf.Bytes()[0] != 0x16 { return } // Extract handshake message length. handshakeMessageLength := binary.BigEndian.Uint16(buf.Bytes()[3:5]) // Copy handshake message (should be a Client Hello). _, err = io.CopyN(&buf, conn, int64(handshakeMessageLength)) 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", "", "user to switch to, if running as root") flag.Parse() hostAndPort = flag.Arg(0) if certFile == "" || keyFile == "" || hostAndPort == "" { fatalError("usage: client-hello-mirror -c cert.pem -k key.pem [-u user] host:port") } // Load cert cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { fatalError(err) } // TLS config tlsConfig := tls.Config{ Certificates: []tls.Certificate{cert}, //MaxVersion: tls.VersionTLS12, NextProtos: []string{"http/1.1"}, } // Listen for connections ln, err := net.Listen("tcp", hostAndPort) if err != nil { fatalError(err) } defer ln.Close() dropPrivileges(userToSwitchTo) 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) } }