client-hello-mirror/server.go

487 lines
17 KiB
Go

// SPDX-FileCopyrightText: 2022-2023 nervuri <https://nervuri.net/contact>
//
// 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 = "<fieldset class=\"meh\"><legend>System time exposure</legend><p>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 <a href=\"https://datatracker.ietf.org/doc/html/draft-mathewson-no-gmtunixtime\" class=\"meh\" target=\"_blank\">has been deprecated</a>.</p><p><b>Exposed time:</b> <code class=\"bad\">" + formatTimestamp(clientHelloMsg.Highlights.GMTUnixTime) + "</code></p><p>This could be a false positive, refresh the page a few times to make sure.</p></fieldset><br/>"
var systemTimeExposedStrNoJS string
if systemTimeExposed {
systemTimeExposedStrNoJS = systemTimeExposedStr
}
if connectionState.DidResume {
sessionResumptionInfo = "<details class=\"meh\"><p>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.</p><p><a href=\"https://svs.informatik.uni-hamburg.de/publications/2018/2018-12-06-Sy-ACSAC-Tracking_Users_across_the_Web_via_TLS_Session_Resumption.pdf\" class=\"meh\" target=\"_blank\">Tracking Users across the Web via TLS Session Resumption</a></p></details>"
} else if req.Query == "" {
sessionResumptionInfo = "<p>If you haven't already, refresh the page to check if your browser supports session resumption.</p>"
}
if !clientHelloMsg.Highlights.SCTSupport ||
!clientHelloMsg.Highlights.OCSPStaplingSupport {
featuresInfo = "<details class=\"bad\"><ul>"
if !clientHelloMsg.Highlights.SCTSupport {
featuresInfo += "<li>Your browser does not validate <a href=\"https://certificate.transparency.dev/howctworks/\" class=\"bad\">Certificate Transparency</a> log signatures.</li>"
}
if !clientHelloMsg.Highlights.OCSPStaplingSupport {
featuresInfo += "<li>Your browser does not support privacy-friendly revocation checking via <a href=\"https://scotthelme.co.uk/ocsp-stapling-speeding-up-ssl/\" class=\"bad\">OCSP stapling</a>.</li>"
}
featuresInfo += "</ul></details>"
}
// 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)
}
}