// SPDX-FileCopyrightText: 2022-2023 nervuri // // SPDX-License-Identifier: BSD-3-Clause package main import ( "bufio" "bytes" "crypto/tls" _ "embed" "encoding/binary" "encoding/hex" "encoding/json" "flag" htmlTemplate "html/template" "io" "log" "net" "os" textTemplate "text/template" "time" "tildegit.org/nervuri/client-hello-mirror/clienthello" ) const readTimeout = 10 // seconds const writeTimeout = 10 // seconds type tlsConnectionInfo struct { TLSVersion clienthello.TLSVersion `json:"tls_version"` CipherSuite clienthello.CipherSuite `json:"cipher_suite"` SessionResumed bool `json:"session_resumed"` NegotiatedProtocol string `json:"alpn_negotiated_protocol"` // ALPN } // Connection wrapper that enables exposing the Client Hello to the // request handler. // See https://github.com/FiloSottile/mostly-harmless/tree/main/talks/asyncnet 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...) } //go:embed frontend/index.html var html string //go:embed frontend/error.html var htmlError string //go:embed frontend/style.css var css string //go:embed frontend/index.gmi var gemtext string // Copy the Client Hello message before starting the TLS handshake. func peek(conn net.Conn, tlsConfig *tls.Config) { defer conn.Close() // Set read timeout. err := conn.SetReadDeadline(time.Now().Add(readTimeout * time.Second)) if err != nil { log.Println("SetReadDeadline error: ", err.Error()) return } 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]) if handshakeMessageLength == 0 { log.Println("Zero-length handshake message") return } // Copy handshake message. _, err = io.CopyN(&buf, conn, int64(handshakeMessageLength)) if err != nil { log.Println(err) return } rawClientHello := buf.Bytes() // Check if this really is a Client Hello message. if rawClientHello[5] != 1 { log.Println("HandshakeType is not client_hello") return } // "Put back" the Client Hello bytes we just read, so that they can be // used in the TLS handshake. Concatenate the read bytes with the // unread bytes using a MultiReader, inside a connection wrapper. pConn := prefixConn{ Conn: conn, Reader: io.MultiReader(&buf, conn), } tlsConnection := tls.Server(pConn, tlsConfig) err = tlsConnection.Handshake() if err != nil { log.Println(err) return } requestHandler(tlsConnection, rawClientHello) } func requestHandler(conn *tls.Conn, rawClientHello []byte) { defer conn.Close() // Read first line. scanner := bufio.NewScanner(conn) scanner.Scan() line := scanner.Text() err := scanner.Err() if err != nil { log.Println(err) return } // Get request info from first line. req, err := getRequestInfo(line) if err != nil { log.Println(err) return } // Parse Client Hello message. var clientHelloMsg clienthello.ClientHelloMsg var inputErr string if req.Query == "" { // Parse the Client Hello of the current connection. if !clientHelloMsg.Unmarshal(rawClientHello) { log.Println("Failed to parse Client Hello message") return } } else { // Parse the Client Hello from the query string. queryBytes, err := hex.DecodeString(req.Query) if err != nil { inputErr = "Query is not a valid hex string" log.Println(inputErr + ": ") log.Println(err) } else { if !clientHelloMsg.Unmarshal(queryBytes) { inputErr = "Query string does not contain a valid Client Hello message" log.Println(inputErr) } } } // Get TLS connection info. connectionState := conn.ConnectionState() tlsConnInfo := tlsConnectionInfo{ connectionState.Version, connectionState.CipherSuite, connectionState.DidResume, connectionState.NegotiatedProtocol, } // Prepare response. var resp = NewResponse(&req) if req.Path == "/" { var sessionResumptionInfo string var systemTimeExposedStr string var featuresInfo string // for debugging - pretend system time is exposed: //clientHelloMsg.Highlights.GMTUnixTime = uint32(time.Now().Unix()) var gmtUnixTimeInt64 = int64(clientHelloMsg.Highlights.GMTUnixTime) var systemTimeExposed = time.Now().Unix()-43200 < gmtUnixTimeInt64 && gmtUnixTimeInt64 < time.Now().Unix()+43200 if req.Protocol == httpProtocol { resp.DisableCaching() if inputErr != "" { // Generate HTML body. var mainHTML bytes.Buffer mainHTMLTemplate := htmlTemplate.New("html_template") mainHTMLTemplate = htmlTemplate.Must(mainHTMLTemplate.Parse(htmlError)) err = mainHTMLTemplate.Execute(&mainHTML, struct { CSS htmlTemplate.CSS InputErr string }{ htmlTemplate.CSS(css), inputErr, }) if err != nil { log.Println(err) return } resp.StatusLine = "HTTP/1.1 400 Bad Request" resp.Body = mainHTML.String() } else { // Convert gmt_unix_time to human-readable form. systemTimeExposedStr = "
System time exposure

It appears that your browser is exposing your system's date and time in the Client Hello message, revealing unnecessary information which contributes to fingerprinting. This behavior used to be required, but has been deprecated.

Exposed time: " + formatTimestamp(clientHelloMsg.Highlights.GMTUnixTime) + "

This could be a false positive, refresh the page a few times to make sure.


" var systemTimeExposedStrNoJS string if systemTimeExposed { systemTimeExposedStrNoJS = systemTimeExposedStr } if connectionState.DidResume { sessionResumptionInfo = "

TLS session resumption marginally speeds up the initiation of connections, but it affects privacy in much the same way that HTTP cookies do: the server provides a unique token that your browser sends back on subsequent connections, allowing the server to link your visits even if your IP address changes. The browser can mitigate this by enforcing a short TLS session lifetime: the paper linked below proposes a limit of 10 minutes or less.

Tracking Users across the Web via TLS Session Resumption

" } else if req.Query == "" { sessionResumptionInfo = "

If you haven't already, refresh the page to check if your browser supports session resumption.

" } if !clientHelloMsg.Highlights.SCTSupport || !clientHelloMsg.Highlights.OCSPStaplingSupport { featuresInfo = "
" } // Generate HTML body. var mainHTML bytes.Buffer mainHTMLTemplate := htmlTemplate.New("html_template") mainHTMLTemplate = htmlTemplate.Must(mainHTMLTemplate.Parse(html)) err = mainHTMLTemplate.Execute(&mainHTML, struct { CSS htmlTemplate.CSS TLSVersion htmlTemplate.HTML CipherSuite htmlTemplate.HTML SessionResumed htmlTemplate.HTML SessionResumptionInfo htmlTemplate.HTML SystemTimeExposedNoJS htmlTemplate.HTML GMTUnixTime uint32 SystemTimeExposedJS string SCTSupport htmlTemplate.HTML OCSPStaplingSupport htmlTemplate.HTML FeaturesInfo htmlTemplate.HTML SupportedTLSVersions htmlTemplate.HTML SupportedCipherSuites htmlTemplate.HTML Extensions htmlTemplate.HTML SupportedGroups htmlTemplate.HTML SignatureSchemes htmlTemplate.HTML JA3 htmlTemplate.HTML JA3MD5 string NJA3v1 htmlTemplate.HTML NJA3v1Hash string }{ htmlTemplate.CSS(css), htmlTemplate.HTML(getTLSVersionHTML(connectionState.Version)), htmlTemplate.HTML(getCipherSuiteHTML(connectionState.CipherSuite)), getBoolHTML(connectionState.DidResume, "meh", ""), htmlTemplate.HTML(sessionResumptionInfo), htmlTemplate.HTML(systemTimeExposedStrNoJS), clientHelloMsg.Highlights.GMTUnixTime, systemTimeExposedStr, getBoolHTML(clientHelloMsg.Highlights.SCTSupport, "good", "bad"), getBoolHTML(clientHelloMsg.Highlights.OCSPStaplingSupport, "good", "bad"), htmlTemplate.HTML(featuresInfo), getTLSVersionsHTML(clientHelloMsg.GetSupportedVersions()), getCipherSuitesHTML(clientHelloMsg.CipherSuites), getExtensionsHTML(clientHelloMsg.Extensions), getSupportedGroupsHTML(clientHelloMsg.GetSupportedGroups()), getSignatureSchemesHTML(clientHelloMsg.GetSignatureSchemes()), htmlTemplate.HTML(formatJA3(clientHelloMsg.Highlights.JA3)), hex.EncodeToString(clientHelloMsg.Highlights.JA3MD5), htmlTemplate.HTML(formatNJA3v1(clientHelloMsg.Highlights.NJA3v1)), hex.EncodeToString(clientHelloMsg.Highlights.NJA3v1Hash), }) if err != nil { log.Println(err) return } resp.Body = mainHTML.String() } } else if req.Protocol == geminiProtocol { if inputErr != "" { resp.StatusLine = "59 " + inputErr } else { if systemTimeExposed { // Convert gmt_unix_time to human-readable form. systemTimeExposedStr = "\n## System time exposure\n\nIt appears that your browser is exposing your system's date and time in the Client Hello message, revealing unnecessary information which contributes to fingerprinting. This behavior used to be required, but has been deprecated.\n\nExposed time: " + formatTimestamp(clientHelloMsg.Highlights.GMTUnixTime) + "\n\nThis could be a false positive, refresh the page a few times to make sure.\n\n=> https://datatracker.ietf.org/doc/html/draft-mathewson-no-gmtunixtime Deprecation of gmt_unix_time\n" } if connectionState.DidResume { sessionResumptionInfo = "\n\nTLS session resumption marginally speeds up the initiation of connections, but it affects privacy in much the same way that HTTP cookies do: the server provides a unique token that your browser sends back on subsequent connections, allowing the server to link your visits even if your IP address changes. The browser can mitigate this by enforcing a short TLS session lifetime: the paper linked below suggests a limit of 10 minutes or less.\n\n=> https://svs.informatik.uni-hamburg.de/publications/2018/2018-12-06-Sy-ACSAC-Tracking_Users_across_the_Web_via_TLS_Session_Resumption.pdf Tracking Users across the Web via TLS Session Resumption" } else if req.Query == "" { sessionResumptionInfo = "\n\nIf you haven't already, refresh the page to check if your browser supports session resumption." } // Generate gemtext body. var mainGemtext bytes.Buffer mainGemtextTemplate := textTemplate.New("gemtext_template") mainGemtextTemplate = textTemplate.Must(mainGemtextTemplate.Parse(gemtext)) err = mainGemtextTemplate.Execute(&mainGemtext, struct { TLSVersion string CipherSuite string SessionResumed bool SessionResumptionInfo string SystemTimeExposed string SCTSupport bool OCSPStaplingSupport bool SupportedTLSVersions string SupportedCipherSuites string Extensions string SupportedGroups string SignatureSchemes string JA3 string JA3MD5 string NJA3v1 string NJA3v1Hash string }{ getTLSVersionGemtext(connectionState.Version), getCipherSuiteGemtext(connectionState.CipherSuite, false), connectionState.DidResume, sessionResumptionInfo, systemTimeExposedStr, clientHelloMsg.Highlights.SCTSupport, clientHelloMsg.Highlights.OCSPStaplingSupport, getTLSVersionsGemtext(clientHelloMsg.GetSupportedVersions()), getCipherSuitesGemtext(clientHelloMsg.CipherSuites), getExtensionsGemtext(clientHelloMsg.Extensions), getSupportedGroupsGemtext(clientHelloMsg.GetSupportedGroups()), getSignatureSchemesGemtext(clientHelloMsg.GetSignatureSchemes()), clientHelloMsg.Highlights.JA3, hex.EncodeToString(clientHelloMsg.Highlights.JA3MD5), clientHelloMsg.Highlights.NJA3v1, hex.EncodeToString(clientHelloMsg.Highlights.NJA3v1Hash), }) if err != nil { log.Println(err) return } resp.Body = mainGemtext.String() } } } else if req.Path == "/json/v1" || req.Path == "/json/v2" { if req.Protocol == httpProtocol { resp.Headers["Content-Type"] = "application/json" resp.DisableCaching() if inputErr != "" { resp.StatusLine = "HTTP/1.1 400 Bad Request" resp.Body = "{\n \"error\": \"" + inputErr + "\"\n}\n" } } else if req.Protocol == geminiProtocol { if inputErr != "" { resp.StatusLine = "59 " + inputErr } else { resp.StatusLine = "20 application/json" } } if inputErr == "" { if req.Path == "/json/v2" { clientHelloMsg.AddInfo() tlsConnInfo.TLSVersion = clienthello.GetTLSVersionInfo( tlsConnInfo.TLSVersion.(uint16), false) tlsConnInfo.CipherSuite = clienthello.GetCipherSuiteInfo( tlsConnInfo.CipherSuite.(uint16), false) } // Prepare output. Don't include connection_info if the Client // Hello message was loaded from the query string. type output1Struct struct { ClientHello clienthello.ClientHelloMsg `json:"client_hello"` TLSConnectionInfo tlsConnectionInfo `json:"connection_info"` } type output2Struct struct { ClientHello clienthello.ClientHelloMsg `json:"client_hello"` } var outputJSON []byte if req.Query == "" { outputJSON, err = json.MarshalIndent(output1Struct{ clientHelloMsg, tlsConnInfo, }, "", " ") } else { outputJSON, err = json.MarshalIndent(output2Struct{ clientHelloMsg, }, "", " ") } if err != nil { log.Println(err) return } resp.Body = string(outputJSON) } } else { if req.Protocol == httpProtocol { resp.StatusLine = "HTTP/1.1 404 Not Found" resp.Headers["Content-Type"] = "text/plain; charset=utf-8" resp.Body = "404 Not Found" } else if req.Protocol == geminiProtocol { resp.StatusLine = "51 Not Found!" } } // Set write timeout. err = conn.SetWriteDeadline(time.Now().Add(writeTimeout * time.Second)) if err != nil { log.Println("SetWriteDeadline error: ", err.Error()) return } // Write response. _, err = conn.Write(resp.Prepare()) 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}, MinVersion: tls.VersionTLS10, 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 { // Wait for a connection. conn, err := ln.Accept() if err != nil { log.Println("Error accepting: ", err.Error()) continue } // Process the request. go peek(conn, &tlsConfig) } }