big UI commit; add NJA3 proper

* add HTML and Gemtext UI
* extend NJA3
* decode hex-encoded Client Hello message sent as query string
* decode compress_certificate extension (RFC 8879)
* update golang.org/x/crypto from v0.5.0 to v0.13.0
* in /json/v2, expose IANA "recommended" boolean field for
  cipher_sutes and signature_algorithms
* suggest certbot's `--deploy-hook` option in INSTALL.md
This commit is contained in:
nervuri 2023-09-20 08:31:19 +00:00
parent 097d0c99df
commit b7322519a8
25 changed files with 1369 additions and 216 deletions

View File

@ -15,6 +15,18 @@ Files: *.gmi
Copyright: 2022-2023 nervuri <https://nervuri.net/contact>
License: BSD-3-Clause
Files: *.tpl
Copyright: 2022-2023 nervuri <https://nervuri.net/contact>
License: BSD-3-Clause
Files: style.css
Copyright: 2023 nervuri <https://nervuri.net/contact>
License: BSD-3-Clause
Files: script.js
Copyright: 2023 nervuri <https://nervuri.net/contact>
License: BSD-3-Clause
Files: clienthello/*.csv
Copyright: IANA and IETF
License: CC0-1.0

22
DOC.md
View File

@ -6,8 +6,6 @@ SPDX-License-Identifier: BSD-3-Clause
# API Documentation
The API is largely stable - fields may be added, but existing fields will not be modified or removed.
There are two JSON endpoints:
* [/json/v1](https://tlsprivacy.nervuri.net/json/v1) - basic
@ -15,11 +13,11 @@ There are two JSON endpoints:
The `v2` endpoint expands numeric identifiers (of TLS versions, cipher suites, signature algorithms, etc) into objects containing more information. The `name` field in these objects is taken from [the IANA registries](https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml); note that these names may differ from those used in TLS libraries ([example](https://ciphersuite.info/cs/TLS_DHE_DSS_WITH_AES_256_CBC_SHA256/)).
The `v1` endpoint is not deprecated, it is a lighter version of `v2`. `v1` is a better fit for CI tests, while `v2` might be preferable on the user-facing side, where you would rather use names instead of numeric identifiers.
The `v1` endpoint is not deprecated, it is a lighter version of `v2`. `v1` is a better fit for CI tests, while `v2` might be preferable on the user-facing side, where names are more appropriate than numeric identifiers.
The outline of the `v1` output is:
```
``` JSON
{
"client_hello": {
"raw": "...",
@ -43,6 +41,8 @@ The outline of the `v1` output is:
"sct_support": false,
"ja3": "...",
"ja3_md5": "..."
"nja3v1": "...",
"nja3v1_sha256_128": "..."
}
},
"connection_info": {
@ -58,25 +58,29 @@ The `client_hello` object contains a `highlights` object, which includes:
* the deprecated `gmt_unix_time` value extracted from the client random, which [should NOT](https://datatracker.ietf.org/doc/html/draft-mathewson-no-gmtunixtime) represent the current timestamp anymore;
* boolean values that show whether the client supports secure renegotiation, OCSP stapling and SCTs (Signed Certificate Timestamps);
* the JA3 fingerprint, both its expanded form and its MD5 hash.
* the [JA3 fingerprint](https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967/), both its expanded form and its MD5 hash;
* the [NJA3 fingerprint](NJA3.md), both its expanded form and its SHA256/128 hash.
Aside from `client_hello`, there is a `connection_info` object containing the TLS version, cipher suite and ALPN protocol that were negotiated for the connection, as well as a boolean value that shows whether the TLS session was resumed.
In `v2` numeric identifiers are expanded, for example `"cipher_suite": 4867` becomes:
```
``` JSON
"cipher_suite": {
"code": 4867,
"hex_code": "1303",
"name": "TLS_CHACHA20_POLY1305_SHA256"
"name": "TLS_CHACHA20_POLY1305_SHA256",
"recommended": true
},
```
More info (such as whether a cipher suite is recommended or not) may be added later.
In both JSON endpoints, raw byte values are represented as hexadecimal strings.
Both JSON endpoints accept a raw hex-encoded Client Hello message as a query string (`/json/v1?16030102...`). If one is provided, the response will contain the decoded query string and the `connection_info` object will be omitted.
IANA-assigned codes for TLS parameters and extensions are documented at:
* [TLS parameters](https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml)
* [TLS extensions](https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml)
The API is largely stable - fields may be added, but existing fields will not be modified or removed.

View File

@ -12,7 +12,7 @@ You'll need Go version 1.19 or later.
Fetch and build the program:
```
``` sh
go install tildegit.org/nervuri/client-hello-mirror@latest
```
@ -20,7 +20,7 @@ The same command can be used to update it.
The resulting binary should now be at `~/go/bin/client-hello-mirror`. Put it somewhere in $PATH, if you wish:
```
``` sh
ln -s ~/go/bin/client-hello-mirror /usr/local/bin/
```
@ -28,7 +28,7 @@ ln -s ~/go/bin/client-hello-mirror /usr/local/bin/
Generate TLS certificate:
```
``` sh
# CA-signed:
certbot certonly --webroot -w /var/www/example.com -d example.com
# or self-signed:
@ -37,7 +37,7 @@ openssl req -new -subj "/CN=example.com" -addext "subjectAltName = DNS:example.c
Run on port 1965:
```
``` sh
~/go/bin/client-hello-mirror -c cert.pem -k privkey.pem :1965
```
@ -47,7 +47,7 @@ In order to run the program as a daemon and auto-start it on boot, you need to m
Sample systemd unit file:
```
``` TOML
[Unit]
Description=TLS Client Hello Mirror
After=network.target
@ -63,12 +63,19 @@ WantedBy=multi-user.target
Modify as needed, save to `/etc/systemd/system/client-hello-mirror.service` and run:
```
``` sh
systemctl enable client-hello-mirror.service
systemctl start client-hello-mirror.service
```
Remember, if you are using a program such as `certbot` to automatically renew the TLS certificate, then you'll also want to restart `client-hello-mirror` afterward, for it to use the new certificate.
Remember, if you are using a program such as `certbot` to automatically renew the TLS certificate, then you'll also want to restart `client-hello-mirror` afterward, for it to use the new certificate. You can do this with a renewal hook (see certbot's `--deploy-hook` option), for example:
``` sh
# Place this in /etc/letsencrypt/renewal-hooks/deploy/restart-services.sh
if echo "$RENEWED_DOMAINS" | grep -q example.com; then
systemctl restart client-hello-mirror.service
fi
```
## Drop root privileges

View File

@ -2,10 +2,17 @@
#
# SPDX-License-Identifier: BSD-3-Clause
dev: check
.PHONY: dev
dev:
mkdir -p build
go build -o build/client-hello-mirror
.PHONY: release
release: check
mkdir -p build
CGO_ENABLED=0 go build -buildmode=pie -trimpath -ldflags="-s -w" -o build/client-hello-mirror
.PHONY: check
check:
golangci-lint run
#go vet
@ -14,6 +21,19 @@ check:
find . -name '*.html' -exec sh -c 'tidy -q -errors -access "{}" || ls "{}"' \;
reuse lint -q
.PHONY: run
run:
killall -9 client-hello-mirror || :
ls *.go */*.go | entr -nsr "make dev && build/client-hello-mirror -c build/cert.pem -k build/privkey.pem :4444"
.PHONY: update
update:
go get -u
go mod tidy -v
.PHONY: dwd
dwd:
wget -O clienthello/cipher-suites.csv https://www.iana.org/assignments/tls-parameters/tls-parameters-4.csv
wget -O clienthello/extensions.csv https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values-1.csv
wget -O clienthello/named-groups.csv https://www.iana.org/assignments/tls-parameters/tls-parameters-8.csv
wget -O clienthello/signature-schemes.csv https://www.iana.org/assignments/tls-parameters/tls-signaturescheme.csv

96
NJA3.md Normal file
View File

@ -0,0 +1,96 @@
<!--
SPDX-FileCopyrightText: 2023 nervuri <https://nervuri.net/contact>
SPDX-License-Identifier: BSD-3-Clause
-->
# NJA3
NJA3 is an algorithm for deriving a fingerprint string from a TLS Client Hello message. It aims to be a more robust and accurate version of [JA3](https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967/). It makes the following changes to JA3:
1. extension codes are sorted in ascending order
2. known conditional extensions are not included: `server_name, padding, pre_shared_key, session_ticket, application_layer_protocol_negotiation, next_protocol_negotiation, token_binding, channel_id, channel_id_old`
3. the following code groups are added:
* record header TLS version
* supported TLS versions
* signature algorithms
* pre-shared key exchange modes
* certificate compression algorithms
4. 16-bit GREASE values are replaced with `0x0A0A` (2570) and 8-bit ones (PskKeyExchangeModes) with `0x0B` (11); their positions are preserved in all code groups except for the extensions group, in which codes are sorted
5. the fingerprint hash is SHA256 truncated to the left 128 bits
Points 1 and 2 aim to make the fingerprint stable in the face of predictable variations in a client's TLS Client Hello message. Extension codes are sorted as an adaptation to [Chromium having randomized the ordering of extensions](https://www.fastly.com/blog/a-first-look-at-chromes-tls-clienthello-permutation-in-the-wild), and several extensions are excluded - namely extensions that clients are known to only send some of the time. Most extensions in the exclusion list are taken from Troy Kent's ["(JA) 3 Reasons to Rethink Your Encrypted Traffic Analysis Strategies"](https://www.youtube-nocookie.com/embed/C93ivdcVL3A).
Points 3-5 make the fingerprint more accurate. NJA3 contains values from within `supported_versions`, `signature_algorithms`, `psk_key_exchange_modes` and `compress_certificate` - extensions that were standardized after JA3 was conceived. The TLS version from the record header is now also included. Each GREASE value is changed to `0x0A0A` (if 16-bit) or `0x0B` (if 8-bit) and its position within each code group is preserved (which is also [what mercury does](https://github.com/cisco/mercury/blob/main/doc/npf.md#tls)). MD5 is replaced with a more collision-resistant hash, while preserving MD5's convenient 16 byte length (again, something which [mercury does as well](https://github.com/cisco/mercury/blob/main/doc/npf.md#hash-representation)).
To sum it up, NJA3v1 is composed of the following code groups:
* record header TLS version
* handshake TLS version
* cipher suites
* extensions (sorted, conditional extensions ignored)
* supported groups (from the `supported_groups` extension)
* supported point formats (from the `ec_point_formats` extension)
* supported TLS versions (from the `supported_versions` extension)
* signature algorithms (from the `signature_algorithms` extension)
* pre-shared key exchange modes (from the `psk_key_exchange_modes` extension)
* certificate compression algorithms (from the `compress_certificate` extension)
Ignored extensions:
* `server_name (0)`
* `padding (21)`
* `pre_shared_key (41)`
* `session_ticket (35)`
* `application_layer_protocol_negotiation (16)`
* `next_protocol_negotiation (13172)`
* `token_binding (24)`
* `channel_id (30032)`
* `channel_id_old (30031)`
Future versions of NJA3 may be defined, to adapt to changes in TLS and to amend shortcomings found in previous versions.
Why this name? The N used to stand for "normalized", which is what the folks at [tlsfingerprint.io](https://tlsfingerprint.io/) call their new fingerprints with sorted extension codes (see [tlsfingerprint.io/norm\_fp](https://tlsfingerprint.io/norm_fp)). However, since NJA3 has come to do more than sort extension codes, let's just say it means "nervuri's take on JA3".
## Example
This is the NJA3v1 fingerprint for Chromium version 116.0.5845.180 running on Debian 12.1:
* NJA3v1: `769,771,2570-4867-4865-4866-52393-52392-49195-49199-49196-49200-49171-49172-156-157-47-53,5-10-11-13-18-23-27-43-45-51-2570-2570-17513-65281,2570-29-23-24,0,2570-772-771,1027-2052-1025-1283-2053-1281-2054-1537,1,2`
* NJA3v1 SHA256/128: `8e0ed9d95486aa6a004a682cebd14afe`
It's the same fingerprint in normal browsing mode and in incognito mode, whether session resumption is used or not. JA3, on the other hand, produces a different fingerprint on every connection.
## Alternate approaches
[Mercury's TLS fingerprint algorithm](https://github.com/cisco/mercury/blob/main/doc/npf.md#tls) ignores any extension codes not found in the following set:
```
TLS_EXT_FIXED = {
0x0001, 0x0005, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x000d,
0x000f, 0x0010, 0x0011, 0x0018, 0x001b, 0x001c, 0x002b, 0x002d,
0x0032, 0x5500
}
```
Ignoring extensions outside of a fixed set has the advantage that future conditional extensions will not affect the fingerprint's stability. Perhaps future versions of NJA3 will use this approach. The drawback is that it makes the fingerprint less precise.
GREASE can be approached in several ways:
* ignore GREASE values completely, as JA3 does;
* normalize GREASE values and maintain their positions, as mercury and NJA3 do;
* mark code groups which contain GREASE values, but ignore the positions of GREASE values within those groups - an intermediary approach.
RFC 8701 [states that](https://www.rfc-editor.org/rfc/rfc8701.html#name-sending-grease-values):
> Implementations SHOULD balance diversity in GREASE advertisements with determinism. For example, a client that randomly varies GREASE value positions for each connection may only fail against a broken server with some probability. This risks the failure being masked by automatic retries. A client that positions GREASE values deterministically over a period of time (such as a single software release) stresses fewer cases but is more likely to detect bugs from those cases.
Following this guideline, Chromium places GREASE values at fixed positions within each list, including the extensions list, even as most real extensions are shuffled. This is what informed the choice of including GREASE positions in NJA3v1 (an exception is made for extensions codes, which are all sorted to simplify implementation). Future versions of NJA3 will ignore GREASE positions if other TLS implementations will be found to randomize them.
On a final note, string-based fingerprinting is fundamentally limited compared to a function-based approach. More advanced fingerprinting solutions store the entire Client Hello message and provide it as input to one or more client detection functions, the output of which can include a confidence level. In addition to TLS parameters and their order, such functions can make use of values within conditional extensions, as well as any perceivable patterns in the TLS implementation's behavior. Other messages in the TLS connection could also be used for fingerprinting - see the Future work section in ["The use of TLS in Censorship Circumvention"](https://tlsfingerprint.io/static/frolov2019.pdf#page=14):
> Client Hello messages provide a rich amount of features useful in fingerprinting TLS implementations, but there are other messages in the TLS connection that could be used to detect or block tools. For instance, once the connection is established and sends encrypted records, the lengths of these encrypted records may reveal differences between implementations
## Implementation
The first implementation is written in Go and can be found [here](https://tildegit.org/nervuri/client-hello-mirror/clienthello/fingerprint.go#L57). This code is part of the TLS Client Hello Mirror, a live instance of which is running at [tlsprivacy.nervuri.net](https://tlsprivacy.nervuri.net/), which will (among other things) generate the NJA3 fingerprint of any HTTPS or [Gemini](https://geminiprotocol.net/) client you connect to it.

View File

@ -8,25 +8,33 @@ SPDX-License-Identifier: BSD-3-Clause
This test:
* reflects the complete Client Hello message, preserving the order in which TLS parameters and extensions are sent;
* reflects the complete [Client Hello](https://tls13.xargs.org/#client-hello) message in multiple forms, preserving the order in which TLS parameters and extensions are sent;
* can be used to check for TLS privacy pitfalls ([session resumption](https://svs.informatik.uni-hamburg.de/publications/2018/2018-12-06-Sy-ACSAC-Tracking_Users_across_the_Web_via_TLS_Session_Resumption.pdf), [TLS fingerprinting](https://tlsfingerprint.io/), [system time exposure](https://datatracker.ietf.org/doc/html/draft-mathewson-no-gmtunixtime));
* supports both HTTP and [Gemini](https://gemini.circumlunar.space/) on the same port;
* supports both HTTP and [Gemini](https://geminiprotocol.net/) on the same port;
* is [free as in freedom](https://www.gnu.org/philosophy/free-sw.en.html) and trivial to self-host.
A live instance is running at [tlsprivacy.nervuri.net](https://tlsprivacy.nervuri.net/).
## Installation
See [INSTALL.md](INSTALL.md).
## API documentation
This test exposes two JSON endpoints: [/json/v1 (basic)](https://tlsprivacy.nervuri.net/json/v1) and [/json/v2 (detailed)](https://tlsprivacy.nervuri.net/json/v2). See [DOC.md](DOC.md) for details.
This test exposes two JSON endpoints:
## Roadmap
* [/json/v1 (basic)](https://tlsprivacy.nervuri.net/json/v1)
* [/json/v2 (detailed)](https://tlsprivacy.nervuri.net/json/v2)
See [DOC.md](DOC.md) for details.
## Wishlist
* HTML & gemtext front-end
* detect client vulnerability to session [prolongation attacks](https://svs.informatik.uni-hamburg.de/publications/2018/2018-12-06-Sy-ACSAC-Tracking_Users_across_the_Web_via_TLS_Session_Resumption.pdf#page=3)
* support early data / 0-RTT (Go's `crypto/tls` library currently does not)
* support sessionID-based resumption (Go's `crypto/tls` library currently does not)
* decode more extensions
* token binding (RFCs 8471-8473, formerly [Channel ID](https://datatracker.ietf.org/doc/html/draft-balfanz-tls-channelid-01)) can be bad for privacy, but Chromium [removed support](https://thenewstack.io/tls-token-binding-standard-gains-a-foothold-on-the-web/) in 2018. Edge might still support it, though. It may be worth testing for it (add to highlights and add warning in the UI).
## Contributing

View File

@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2022-2023 nervuri <https://nervuri.net/contact>
//
// SPDX-License-Identifier: BSD-3-Clause
package clienthello
import "fmt"
// RFC 8879, Section 3
// https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#tls-certificate-compression-algorithm-ids
var CertComprAlgoList = map[uint16]CertComprAlgoInfo{
1: {Code: 1, Name: "zlib"},
2: {Code: 2, Name: "brotli"},
3: {Code: 3, Name: "zstd"},
}
type CertComprAlgo any // (uint16 | CertComprAlgoInfo)
type CertComprAlgoInfo struct {
Code uint16 `json:"code"`
Name string `json:"name"`
}
func GetCertComprAlgoInfo(algoCode uint16, mustName bool) CertComprAlgoInfo {
info, found := CertComprAlgoList[algoCode]
if !found {
info = CertComprAlgoInfo{
Code: algoCode,
}
}
if mustName && info.Name == "" {
info.Name = fmt.Sprint("(", info.Code, ")")
}
return info
}
func (m *ClientHelloMsg) AddCertComprAlgoInfo() {
for i, ext := range m.Extensions {
if ext.Code == extensionCompressCertificate {
for j, algoCode := range ext.Data.CertificateCompressionAlgos {
m.Extensions[i].Data.CertificateCompressionAlgos[j] =
GetCertComprAlgoInfo(algoCode.(uint16), false)
}
}
}
}
func (m *ClientHelloMsg) GetCertComprAlgos() []CertComprAlgo {
var algos []CertComprAlgo
for _, ext := range m.Extensions {
if ext.Code == extensionCompressCertificate {
algos = append(algos, ext.Data.CertificateCompressionAlgos...)
}
}
return algos
}

View File

@ -30,7 +30,7 @@ type CipherSuiteInfo = struct {
Code uint16 `json:"code"`
HexCode string `json:"hex_code"`
Name string `json:"name"`
Recommended bool `json:"-"`
Recommended bool `json:"recommended"`
Reference string `json:"-"`
}
@ -92,7 +92,7 @@ func parseCipherSuitesCSV() map[uint16]CipherSuiteInfo {
return cipherSuites
}
func GetCipherSuiteInfo(cipherSuiteCode uint16) CipherSuiteInfo {
func GetCipherSuiteInfo(cipherSuiteCode uint16, mustName bool) CipherSuiteInfo {
info, found := CipherSuiteList[cipherSuiteCode]
if !found {
info = CipherSuiteInfo{
@ -100,11 +100,14 @@ func GetCipherSuiteInfo(cipherSuiteCode uint16) CipherSuiteInfo {
HexCode: fmt.Sprintf("%04X", cipherSuiteCode),
}
}
if mustName && info.Name == "" {
info.Name = "0x" + info.HexCode
}
return info
}
func (m *ClientHelloMsg) AddCipherSuiteInfo() {
for i, suite := range m.CipherSuites {
m.CipherSuites[i] = GetCipherSuiteInfo(suite.(uint16))
m.CipherSuites[i] = GetCipherSuiteInfo(suite.(uint16), false)
}
}

View File

@ -10,19 +10,16 @@
package clienthello
import (
"crypto/md5"
"encoding/binary"
"encoding/hex"
"log"
"sort"
"strconv"
"strings"
"golang.org/x/crypto/cryptobyte"
)
// Check if this is a GREASE value (RFC 8701).
func isGREASE(value uint16) bool {
// Check if this is a 16-bit GREASE value (RFC 8701).
func isGREASE16(value uint16) bool {
// Values for: cipher suites, ALPN,
// extensions, named groups, signature algorithms and TLS versions:
greaseValues := [16]uint16{
@ -38,6 +35,21 @@ func isGREASE(value uint16) bool {
return false
}
// Check if this is an 8-bit GREASE value (PskKeyExchangeModes) (RFC 8701).
func isGREASE8(value uint8) bool {
// Values for PskKeyExchangeModes:
greaseValues := [8]uint8{
0x0B, 0x2A, 0x49, 0x68, 0x87, 0xA6, 0xC5, 0xE4,
}
// Run the check
for _, greaseValue := range greaseValues {
if value == greaseValue {
return true
}
}
return false
}
// readUint8LengthPrefixed acts like s.ReadUint8LengthPrefixed, but targets a
// []byte instead of a cryptobyte.String.
func readUint8LengthPrefixed(s *cryptobyte.String, out *[]byte) bool {
@ -64,11 +76,11 @@ type highlights struct {
OCSPStaplingSupport bool `json:"ocsp_stapling_support"`
SCTSupport bool `json:"sct_support"`
// Go's crypto/tls server does not support early data.
EarlyData bool `json:"-"` // don't include in JSON
JA3 string `json:"ja3"`
JA3MD5 byteSlice `json:"ja3_md5"`
NJA3 string `json:"nja3"` // normalized JA3 (extensions sorted)
NJA3MD5 byteSlice `json:"nja3_md5"`
EarlyData bool `json:"-"` // don't include in JSON
JA3 string `json:"ja3"`
JA3MD5 byteSlice `json:"ja3_md5"`
NJA3v1 string `json:"nja3v1"`
NJA3v1Hash byteSlice `json:"nja3v1_sha256_128"`
}
type ClientHelloMsg struct {
@ -139,6 +151,8 @@ func (m *ClientHelloMsg) Unmarshal(data []byte) bool {
return false
}
seenExts := make(map[uint16]bool)
for !extensions.Empty() {
var extCode uint16
var extData cryptobyte.String
@ -146,6 +160,13 @@ func (m *ClientHelloMsg) Unmarshal(data []byte) bool {
!extensions.ReadUint16LengthPrefixed(&extData) {
return false
}
// Duplicate extensions are prohibited.
if seenExts[extCode] {
return false
}
seenExts[extCode] = true
var extension = GetExtensionInfo(extCode)
extension.Data.Raw = []byte(extData)
@ -179,10 +200,6 @@ func (m *ClientHelloMsg) Unmarshal(data []byte) bool {
return false
}
}
case extensionNextProtoNeg:
// Next Protocol Negotiation
// draft-agl-tls-nextprotoneg-04 (not IANA assiged)
extension.Name = "next_protocol_negotiation"
case extensionStatusRequest:
// Certificate Status Request
// RFC 4366, Section 3.6
@ -384,12 +401,38 @@ func (m *ClientHelloMsg) Unmarshal(data []byte) bool {
case extensionCompressCertificate:
// Certificate Compression
// RFC 8879
// TODO: parse - https://www.rfc-editor.org/rfc/rfc8879.html
var algoList cryptobyte.String
extension.Data.CertificateCompressionAlgos = []CertComprAlgo{}
if !extData.ReadUint8LengthPrefixed(&algoList) ||
algoList.Empty() {
return false
}
for !algoList.Empty() {
var algo uint16
if !algoList.ReadUint16(&algo) {
return false
}
extension.Data.CertificateCompressionAlgos = append(
extension.Data.CertificateCompressionAlgos, algo)
}
// Draft extensions (not IANA assigned)
case extensionApplicationSettings:
// Application-Layer Protocol Settings
// draft-vvv-tls-alps (not IANA assiged)
extension.Name = " application_settings"
// draft-vvv-tls-alps
extension.Name = "application_settings"
// TODO: parse - https://datatracker.ietf.org/doc/html/draft-vvv-tls-alps
case extensionNextProtoNeg:
// Next Protocol Negotiation
// draft-agl-tls-nextprotoneg-04
extension.Name = "next_protocol_negotiation"
case extensionChannelID:
// Channel ID
// draft-balfanz-tls-channelid-01
extension.Name = "channel_id"
case extensionChannelIDOld:
// Channel ID (old)
// draft-balfanz-tls-channelid-00
extension.Name = "channel_id_old"
}
m.Extensions = append(m.Extensions, extension)
@ -405,72 +448,9 @@ func (m *ClientHelloMsg) Unmarshal(data []byte) bool {
//}
}
// JA3
var ja3 strings.Builder
var supportedGroups []NamedGroup
var supportedPointFormats []ECPointFormat
ja3.WriteString(strconv.FormatUint(uint64(m.TLSVersion.(uint16)), 10) + ",")
for i, cs := range m.CipherSuites {
if !isGREASE(cs.(uint16)) { // ignore GREASE values
ja3.WriteString(strconv.FormatUint(uint64(cs.(uint16)), 10))
if i+1 != len(m.CipherSuites) { // if not last element, add "-"
ja3.WriteString("-")
}
}
}
ja3.WriteString(",")
var extCodes []uint16 // non-GREASE extension codes
for i, e := range m.Extensions {
if !isGREASE(e.Code) { // ignore GREASE values
ja3.WriteString(strconv.FormatUint(uint64(e.Code), 10))
if i+1 != len(m.Extensions) { // if not last element, add "-"
ja3.WriteString("-")
}
extCodes = append(extCodes, e.Code)
if e.Code == extensionSupportedGroups {
supportedGroups = e.Data.SupportedGroups
} else if e.Code == extensionSupportedPointFormats {
supportedPointFormats = e.Data.SupportedPointFormats
}
}
}
ja3.WriteString(",")
for i, g := range supportedGroups {
if !isGREASE(g.(uint16)) { // ignore GREASE values
ja3.WriteString(strconv.FormatUint(uint64(g.(uint16)), 10))
if i+1 != len(supportedGroups) { // if not last element, add "-"
ja3.WriteString("-")
}
}
}
ja3.WriteString(",")
for i, pf := range supportedPointFormats {
ja3.WriteString(strconv.FormatUint(uint64(pf.(uint8)), 10))
if i+1 != len(supportedPointFormats) { // if not last element, add "-"
ja3.WriteString("-")
}
}
m.Highlights.JA3 = ja3.String()
hash := md5.Sum([]byte(m.Highlights.JA3))
m.Highlights.JA3MD5 = hash[:]
// Make normalized JA3:
// 1. sort extension codes;
sort.Slice(extCodes, func(i, j int) bool { return extCodes[i] < extCodes[j] })
// 2. build string of sorted codes;
var sortedExtString strings.Builder
for i, code := range extCodes {
sortedExtString.WriteString(strconv.FormatUint(uint64(code), 10))
if i+1 != len(extCodes) { // if not last element, add "-"
sortedExtString.WriteString("-")
}
}
// 3. replace extensions part of the JA3 string.
splitJA3 := strings.Split(m.Highlights.JA3, ",")
splitJA3[2] = sortedExtString.String()
m.Highlights.NJA3 = strings.Join(splitJA3, ",")
nhash := md5.Sum([]byte(m.Highlights.NJA3))
m.Highlights.NJA3MD5 = nhash[:]
// Add TLS fingerprints: JA3 & NJA3
m.ja3()
m.nja3()
return true
}
@ -484,4 +464,5 @@ func (m *ClientHelloMsg) AddInfo() {
m.AddECPointFormatInfo()
m.AddCompressionMethodInfo()
m.AddPSKModeInfo()
m.AddCertComprAlgoInfo()
}

View File

@ -37,6 +37,7 @@ const (
extensionPadding uint16 = 21
extensionEncryptThenMac uint16 = 22
extensionExtendedMasterSecret uint16 = 23
extensionTokenBinding uint16 = 24
extensionCompressCertificate uint16 = 27
extensionSessionTicket uint16 = 35
extensionPreSharedKey uint16 = 41
@ -50,6 +51,8 @@ const (
extensionKeyShare uint16 = 51
extensionNextProtoNeg uint16 = 13172 // not IANA assigned
extensionApplicationSettings uint16 = 17513 // not IANA assigned
extensionChannelIDOld uint16 = 30031 // 0x754f - not IANA assigned
extensionChannelID uint16 = 30032 // 0x7550 - not IANA assigned
extensionRenegotiationInfo uint16 = 0xff01
)
@ -78,6 +81,7 @@ type ExtensionData struct {
ALPNProtocols []string `json:"alpn_protocols,omitempty"`
SupportedVersions []TLSVersion `json:"supported_tls_versions,omitempty"`
Cookie byteSlice `json:"cookie,omitempty"`
CertificateCompressionAlgos []CertComprAlgo `json:"compression_algorithms,omitempty"`
KeyShares []KeyShare `json:"key_shares,omitempty"`
PSKModes []PSKMode `json:"psk_modes,omitempty"`
PSKIdentities []PSKIdentity `json:"psk_identities,omitempty"`

136
clienthello/fingerprint.go Normal file
View File

@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2023 nervuri <https://nervuri.net/contact>
//
// SPDX-License-Identifier: BSD-3-Clause
package clienthello
import (
"crypto/md5"
"crypto/sha256"
"sort"
"strconv"
"strings"
)
func toString[N uint8 | uint16](val N) string {
return strconv.FormatUint(uint64(val), 10)
}
func deGREASE16(val uint16, greaseReplacement string) string {
if isGREASE16(val) {
if greaseReplacement == "" {
return ""
} else {
return greaseReplacement + "-"
}
} else {
return toString(val) + "-"
}
}
func deGREASE8(val uint8, greaseReplacement string) string {
if isGREASE8(val) {
if greaseReplacement == "" {
return ""
} else {
return greaseReplacement + "-"
}
} else {
return toString(val) + "-"
}
}
func (m *ClientHelloMsg) ja3() {
var codeGroups [5]string
codeGroups[0] = toString(m.TLSVersion.(uint16))
for _, cs := range m.CipherSuites {
codeGroups[1] += deGREASE16(cs.(uint16), "")
}
for _, e := range m.Extensions {
codeGroups[2] += deGREASE16(e.Code, "")
if e.Code == extensionSupportedGroups {
for _, g := range e.Data.SupportedGroups {
codeGroups[3] += deGREASE16(g.(uint16), "")
}
} else if e.Code == extensionSupportedPointFormats {
for _, pf := range e.Data.SupportedPointFormats {
codeGroups[4] += toString(pf.(uint8)) + "-"
}
}
}
for i, cg := range codeGroups {
codeGroups[i] = strings.TrimSuffix(cg, "-")
}
m.Highlights.JA3 = strings.Join(codeGroups[:], ",")
hash := md5.Sum([]byte(m.Highlights.JA3))
m.Highlights.JA3MD5 = hash[:]
}
func (m *ClientHelloMsg) nja3() {
const genericGREASECode16 = uint16(0x0a0a) // 2570
const genericGREASECode8 = uint8(0x0B) // 11
var genericGreaseString16 = toString(genericGREASECode16)
var genericGreaseString8 = toString(genericGREASECode8)
var codeGroups [10]string
var extCodes []uint16
codeGroups[0] = toString(m.RecordHeaderTLSVersion.(uint16))
codeGroups[1] = toString(m.TLSVersion.(uint16))
for _, cs := range m.CipherSuites {
codeGroups[2] += deGREASE16(cs.(uint16), genericGreaseString16)
}
for _, e := range m.Extensions {
// Ignore conditional extensions.
if e.Code == extensionServerName ||
e.Code == extensionPadding ||
e.Code == extensionPreSharedKey ||
e.Code == extensionSessionTicket || // Firefox
e.Code == extensionALPN || // iTunes
e.Code == extensionNextProtoNeg || // iTunes
e.Code == extensionTokenBinding || // Edge
e.Code == extensionChannelID || // Chrome
e.Code == extensionChannelIDOld {
continue
}
if isGREASE16(e.Code) {
e.Code = genericGREASECode16
}
extCodes = append(extCodes, e.Code)
if e.Code == extensionSupportedGroups {
for _, g := range e.Data.SupportedGroups {
codeGroups[4] += deGREASE16(g.(uint16), genericGreaseString16)
}
} else if e.Code == extensionSupportedPointFormats {
for _, pf := range e.Data.SupportedPointFormats {
codeGroups[5] += toString(pf.(uint8)) + "-"
}
} else if e.Code == extensionSupportedVersions {
for _, v := range e.Data.SupportedVersions {
codeGroups[6] += deGREASE16(v.(uint16), genericGreaseString16)
}
} else if e.Code == extensionSignatureAlgorithms {
for _, sa := range e.Data.SupportedSignatureAlgorithms {
codeGroups[7] += deGREASE16(sa.(uint16), genericGreaseString16)
}
} else if e.Code == extensionPSKModes {
for _, mode := range e.Data.PSKModes {
codeGroups[8] += deGREASE8(mode.(uint8), genericGreaseString8)
}
} else if e.Code == extensionCompressCertificate {
for _, algo := range e.Data.CertificateCompressionAlgos {
codeGroups[9] += toString(algo.(uint16)) + "-"
}
}
}
// Sort extension codes.
sort.Slice(extCodes, func(i, j int) bool { return extCodes[i] < extCodes[j] })
// Add sorted extension codes to NJA3 string.
for _, code := range extCodes {
codeGroups[3] += deGREASE16(code, genericGreaseString16)
}
for i, cg := range codeGroups {
codeGroups[i] = strings.TrimSuffix(cg, "-")
}
m.Highlights.NJA3v1 = strings.Join(codeGroups[:], ",")
hash := sha256.Sum256([]byte(m.Highlights.NJA3v1))
m.Highlights.NJA3v1Hash = hash[:16]
}

View File

@ -90,7 +90,7 @@ func parseNamedGroupsCSV() map[uint16]NamedGroupInfo {
return namedGroups
}
func GetNamedGroupInfo(namedGroupCode uint16) NamedGroupInfo {
func GetNamedGroupInfo(namedGroupCode uint16, mustName bool) NamedGroupInfo {
info, found := NamedGroupList[namedGroupCode]
if !found {
info = NamedGroupInfo{
@ -98,6 +98,9 @@ func GetNamedGroupInfo(namedGroupCode uint16) NamedGroupInfo {
HexCode: fmt.Sprintf("%04X", namedGroupCode),
}
}
if mustName && info.Name == "" {
info.Name = "0x" + info.HexCode
}
return info
}
@ -106,13 +109,24 @@ func (m *ClientHelloMsg) AddNamedGroupInfo() {
switch ext.Code {
case extensionSupportedGroups:
for j, group := range ext.Data.SupportedGroups {
m.Extensions[i].Data.SupportedGroups[j] = GetNamedGroupInfo(group.(uint16))
m.Extensions[i].Data.SupportedGroups[j] =
GetNamedGroupInfo(group.(uint16), false)
}
case extensionKeyShare:
for j, kShare := range ext.Data.KeyShares {
m.Extensions[i].Data.KeyShares[j].Group = GetNamedGroupInfo(
kShare.Group.(uint16))
m.Extensions[i].Data.KeyShares[j].Group =
GetNamedGroupInfo(kShare.Group.(uint16), false)
}
}
}
}
func (m *ClientHelloMsg) GetSupportedGroups() []NamedGroup {
var supportedGroups []NamedGroup
for _, ext := range m.Extensions {
if ext.Code == extensionSupportedGroups {
supportedGroups = append(supportedGroups, ext.Data.SupportedGroups...)
}
}
return supportedGroups
}

View File

@ -4,6 +4,8 @@
package clienthello
import "fmt"
// https://www.iana.org/assignments/tls-parameters/tls-parameters.xml#tls-parameters-9
var ECPointFormatList = map[uint8]ECPointFormatInfo{
0: {Code: 0, Name: "uncompressed", Reference: "[RFC8422]"},
@ -19,13 +21,16 @@ type ECPointFormatInfo struct {
Reference string `json:"-"`
}
func GetECPointFormatInfo(ecPointFormatCode uint8) ECPointFormatInfo {
func GetECPointFormatInfo(ecPointFormatCode uint8, mustName bool) ECPointFormatInfo {
info, found := ECPointFormatList[ecPointFormatCode]
if !found {
info = ECPointFormatInfo{
Code: ecPointFormatCode,
}
}
if mustName && info.Name == "" {
info.Name = fmt.Sprint("(", info.Code, ")")
}
return info
}
@ -33,9 +38,20 @@ func (m *ClientHelloMsg) AddECPointFormatInfo() {
for i, ext := range m.Extensions {
if ext.Code == extensionSupportedPointFormats {
for j, pointFormat := range ext.Data.SupportedPointFormats {
m.Extensions[i].Data.SupportedPointFormats[j] = GetECPointFormatInfo(
pointFormat.(uint8))
m.Extensions[i].Data.SupportedPointFormats[j] =
GetECPointFormatInfo(pointFormat.(uint8), false)
}
}
}
}
func (m *ClientHelloMsg) GetSupportedPointFormats() []ECPointFormat {
var supportedPointFormats []ECPointFormat
for _, ext := range m.Extensions {
if ext.Code == extensionSupportedPointFormats {
supportedPointFormats = append(
supportedPointFormats, ext.Data.SupportedPointFormats...)
}
}
return supportedPointFormats
}

View File

@ -29,10 +29,41 @@ type SignatureSchemeInfo struct {
Code uint16 `json:"code"`
HexCode string `json:"hex_code"`
Name string `json:"name"`
Recommended bool `json:"-"`
Recommended bool `json:"recommended"`
Reference string `json:"-"`
}
// https://www.rfc-editor.org/rfc/rfc5246.html#section-7.4.1.4.1
type oldAlgo struct {
Code uint8
Name string
Reference string
}
// https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-18
var oldHashAlgoList = map[uint8]oldAlgo{
0: {Code: 0, Name: "none", Reference: "[RFC5246]"},
1: {Code: 1, Name: "md5", Reference: "[RFC5246]"},
2: {Code: 2, Name: "sha1", Reference: "[RFC5246]"},
3: {Code: 3, Name: "sha224", Reference: "[RFC5246]"},
4: {Code: 4, Name: "sha256", Reference: "[RFC5246]"},
5: {Code: 5, Name: "sha384", Reference: "[RFC5246]"},
6: {Code: 6, Name: "sha512", Reference: "[RFC5246]"},
8: {Code: 8, Name: "", Reference: "[RFC8422]"}, // Intrinsic
}
// https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-16
var oldSigAlgoList = map[uint8]oldAlgo{
0: {Code: 0, Name: "anonymous", Reference: "[RFC5246]"},
1: {Code: 1, Name: "rsa", Reference: "[RFC5246]"},
2: {Code: 2, Name: "dsa", Reference: "[RFC5246]"},
3: {Code: 3, Name: "ecdsa", Reference: "[RFC5246]"},
7: {Code: 7, Name: "ed25519", Reference: "[RFC8422]"},
8: {Code: 8, Name: "ed448", Reference: "[RFC8422]"},
64: {Code: 64, Name: "gostr34102012_256", Reference: "[RFC9189]"},
65: {Code: 65, Name: "gostr34102012_512", Reference: "[RFC9189]"},
}
func parseSignatureSchemesCSV() map[uint16]SignatureSchemeInfo {
signatureSchemes := map[uint16]SignatureSchemeInfo{}
r := csv.NewReader(strings.NewReader(signatureSchemesCSV))
@ -76,9 +107,9 @@ func parseSignatureSchemesCSV() map[uint16]SignatureSchemeInfo {
if rec == "Y" {
sigScheme.Recommended = true
}
if ref == "[RFC8701]" || isGREASE(sigScheme.Code) {
// (We call isGREASE() because the IANA signature scheme registry
// doesn't currently include GREASE values).
if ref == "[RFC8701]" {
// Note: as of September 2023, the IANA signature scheme registry
// doesn't include GREASE values.
sigScheme.Name = "GREASE"
}
@ -87,13 +118,48 @@ func parseSignatureSchemesCSV() map[uint16]SignatureSchemeInfo {
return signatureSchemes
}
func GetSignatureSchemeInfo(sigSchemeCode uint16) SignatureSchemeInfo {
func GetSignatureSchemeInfo(sigSchemeCode uint16, mustName bool) SignatureSchemeInfo {
info, found := SignatureSchemeList[sigSchemeCode]
if !found {
info = SignatureSchemeInfo{
Code: sigSchemeCode,
HexCode: fmt.Sprintf("%04X", sigSchemeCode),
}
if isGREASE16(sigSchemeCode) {
// As of September 2023, the IANA signature scheme registry
// doesn't include GREASE values, so we need to check here.
info.Name = "GREASE"
info.Reference = "[RFC8701]"
} else {
// If info for this code was not found in the signature schemes
// CSV file, then we might be dealing with values which were
// removed in TLSv1.3, so we can try to derive the name by
// concatenating hash and signature algorithm names.
var hashAlgoCode = uint8(sigSchemeCode >> 8) // first octet
var sigAlgoCode = uint8(sigSchemeCode) // second octet
// info.Name will be empty if either hashAlgoCode or sigAlgoCode
// is not known.
hashInfo, found := oldHashAlgoList[hashAlgoCode]
if found {
info.Name = hashInfo.Name
if info.Name != "" {
info.Name += ","
}
sigInfo, found := oldSigAlgoList[sigAlgoCode]
if found {
info.Name += sigInfo.Name
} else {
info.Name = ""
}
}
info.Reference = oldHashAlgoList[hashAlgoCode].Reference
if info.Reference != oldSigAlgoList[sigAlgoCode].Reference {
info.Reference += oldSigAlgoList[sigAlgoCode].Reference
}
}
}
if mustName && info.Name == "" {
info.Name = "0x" + info.HexCode
}
return info
}
@ -103,9 +169,20 @@ func (m *ClientHelloMsg) AddSignatureSchemeInfo() {
if ext.Code == extensionSignatureAlgorithms ||
ext.Code == extensionSignatureAlgorithmsCert {
for j, sigAlg := range ext.Data.SupportedSignatureAlgorithms {
m.Extensions[i].Data.SupportedSignatureAlgorithms[j] = GetSignatureSchemeInfo(
sigAlg.(uint16))
m.Extensions[i].Data.SupportedSignatureAlgorithms[j] =
GetSignatureSchemeInfo(sigAlg.(uint16), false)
}
}
}
}
func (m *ClientHelloMsg) GetSignatureSchemes() []SignatureScheme {
var supportedSchemes []SignatureScheme
for _, ext := range m.Extensions {
if ext.Code == extensionSignatureAlgorithms {
supportedSchemes = append(supportedSchemes,
ext.Data.SupportedSignatureAlgorithms...)
}
}
return supportedSchemes
}

View File

@ -41,7 +41,7 @@ type TLSVersionInfo struct {
Name string `json:"name"`
}
func GetTLSVersionInfo(tlsVersionCode uint16) TLSVersionInfo {
func GetTLSVersionInfo(tlsVersionCode uint16, mustName bool) TLSVersionInfo {
info, found := TLSVersionList[tlsVersionCode]
if !found {
info = TLSVersionInfo{
@ -49,18 +49,36 @@ func GetTLSVersionInfo(tlsVersionCode uint16) TLSVersionInfo {
HexCode: fmt.Sprintf("%04X", tlsVersionCode),
}
}
if mustName && info.Name == "" {
info.Name = "0x" + info.HexCode
}
return info
}
func (m *ClientHelloMsg) AddTLSVersionInfo() {
m.RecordHeaderTLSVersion = GetTLSVersionInfo(m.RecordHeaderTLSVersion.(uint16))
m.TLSVersion = GetTLSVersionInfo(m.TLSVersion.(uint16))
m.RecordHeaderTLSVersion = GetTLSVersionInfo(
m.RecordHeaderTLSVersion.(uint16), false)
m.TLSVersion = GetTLSVersionInfo(m.TLSVersion.(uint16), false)
for i, ext := range m.Extensions {
if ext.Code == extensionSupportedVersions {
for j, versionCode := range ext.Data.SupportedVersions {
m.Extensions[i].Data.SupportedVersions[j] = GetTLSVersionInfo(
versionCode.(uint16))
versionCode.(uint16), false)
}
}
}
}
func (m *ClientHelloMsg) GetSupportedVersions() []TLSVersion {
var supportedVersions []TLSVersion
for _, ext := range m.Extensions {
if ext.Code == extensionSupportedVersions {
supportedVersions = append(supportedVersions, ext.Data.SupportedVersions...)
}
}
if len(supportedVersions) == 0 {
// Fall back to version field.
supportedVersions = append(supportedVersions, m.TLSVersion)
}
return supportedVersions
}

26
error.html Normal file
View File

@ -0,0 +1,26 @@
<!--
SPDX-FileCopyrightText: 2022-2023 nervuri <https://nervuri.net/contact>
SPDX-License-Identifier: BSD-3-Clause
-->
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000"/>
<meta name="referrer" content="no-referrer"/>
<title>TLS Client Hello Mirror</title>
<style>{{.CSS}}</style>
</head>
<body>
<h1>TLS Client Hello Mirror</h1>
<h2>Error</h2>
<p>{{.InputErr}}</p>
<p><a href="/">Home</a></p>
</body>
</html>

295
formatting.go Normal file
View File

@ -0,0 +1,295 @@
// SPDX-FileCopyrightText: 2022-2023 nervuri <https://nervuri.net/contact>
//
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
htmlTemplate "html/template"
"log"
"strings"
"time"
"tildegit.org/nervuri/client-hello-mirror/clienthello"
)
func toHex(n uint16) string { // for extension codes
return fmt.Sprintf("%04X", n)
}
// Convert Unix timestamp to human-readable form.
func formatTimestamp(timestamp uint32) string {
gmtUnixTime := time.Unix(int64(timestamp), 0)
tz, err := time.LoadLocation("GMT")
if err != nil {
log.Println(err)
}
return gmtUnixTime.In(tz).Format(time.UnixDate)
}
// === HTML functions ===
func getBoolHTML(b bool, trueClass, falseClass string) htmlTemplate.HTML {
if b {
return htmlTemplate.HTML("<span class=\"" + trueClass + "\">true</span>")
} else {
return htmlTemplate.HTML("<span class=\"" + falseClass + "\">false</span>")
}
}
func getTLSVersionHTML(v clienthello.TLSVersion) string {
var s string
vInfo := clienthello.GetTLSVersionInfo(v.(uint16), true)
if vInfo.Name == "GREASE" {
s = "<span class=\"dim\">0x" + vInfo.HexCode + " (GREASE)</span>"
} else if v.(uint16) < 771 { // < TLSv1.2
s = "<span class=\"bad\">" + vInfo.Name + " (deprecated)</span>"
} else if v.(uint16) == 771 { // = TLSv1.2
s = vInfo.Name
} else { // TLSv1.3 or later
s = "<span class=\"good\">" + vInfo.Name + "</span>"
}
return s
}
func getTLSVersionsHTML(versions []clienthello.TLSVersion) htmlTemplate.HTML {
var s = "<ul>\n"
for _, v := range versions {
s += "<li><code>" + getTLSVersionHTML(v.(uint16)) + "</code></li>\n"
}
s += "</ul>\n"
return htmlTemplate.HTML(s)
}
func getCipherSuiteHTML(cs clienthello.CipherSuite) string {
var s string
csInfo := clienthello.GetCipherSuiteInfo(cs.(uint16), true)
if csInfo.Name == "GREASE" {
s = "<span class=\"dim\">0x" + csInfo.HexCode + " (GREASE)</span>"
} else {
visibleName := strings.Join(strings.Split(csInfo.Name, "_"), "_<wbr/>")
if csInfo.Recommended {
if csInfo.HexCode[:2] == "13" {
s = "<a href=\"https://ciphersuite.info/cs/" + csInfo.Name +
"/\" class=\"good\" target=\"_blank\">" + visibleName + "</a>"
} else {
s = "<a href=\"https://ciphersuite.info/cs/" + csInfo.Name +
"/\" target=\"_blank\">" + visibleName + "</a>"
}
} else {
s = "<a href=\"https://ciphersuite.info/cs/" + csInfo.Name +
"/\" class=\"meh\" target=\"_blank\">" + visibleName +
" (not recommended)</a>"
}
}
return s
}
func getCipherSuitesHTML(suites []clienthello.CipherSuite) htmlTemplate.HTML {
var s = "<ul>\n"
for _, cs := range suites {
s += "<li><code>" + getCipherSuiteHTML(cs.(uint16)) + "</code></li>\n"
}
s += "</ul>\n"
return htmlTemplate.HTML(s)
}
func getExtensionsHTML(extensions []clienthello.Extension) htmlTemplate.HTML {
var s = "<ul>\n"
for _, ext := range extensions {
if ext.Name == "GREASE" {
s += "<li><code class=\"dim\">0x" + toHex(ext.Code) + " (GREASE)</code></li>\n"
} else {
if ext.Name == "" {
s += "<li><code>" + fmt.Sprint(ext.Code) + "</code></li>\n"
} else {
ext.Name = strings.Join(strings.Split(ext.Name, "_"), "_<wbr/>")
s += "<li><code>" + ext.Name + "</code></li>\n"
}
}
}
s += "</ul>\n"
return htmlTemplate.HTML(s)
}
func getSupportedGroupsHTML(groups []clienthello.NamedGroup) htmlTemplate.HTML {
var s = "<ul>\n"
for _, g := range groups {
gInfo := clienthello.GetNamedGroupInfo(g.(uint16), true)
if gInfo.Name == "GREASE" {
s += "<li><code class=\"dim\">0x" + gInfo.HexCode +
" (GREASE)</code></li>\n"
} else {
s += "<li><code>" + gInfo.Name + "</code></li>\n"
}
}
s += "</ul>\n"
return htmlTemplate.HTML(s)
}
//func getSupportedPointFormatsHTML(pFormats []clienthello.ECPointFormat) htmlTemplate.HTML {
// var s = "<ul>\n"
// for _, pf := range pFormats {
// name := clienthello.GetECPointFormatInfo(pf.(uint8), true).Name
// name = strings.Join(strings.Split(name, "_"), "_<wbr/>")
// s += "<li><code>" + name + "</code></li>\n"
// }
// s += "</ul>\n"
// return htmlTemplate.HTML(s)
//}
func getSignatureSchemesHTML(sigSchemes []clienthello.SignatureScheme) htmlTemplate.HTML {
var s = "<ul>\n"
for _, ss := range sigSchemes {
schemeInfo := clienthello.GetSignatureSchemeInfo(ss.(uint16), true)
schemeInfo.Name = strings.Join(strings.Split(schemeInfo.Name, "_"), "_<wbr/>")
if schemeInfo.Name == "GREASE" {
s += "<li><code class=\"dim\">" + "0x" + schemeInfo.HexCode +
" (GREASE)</code></li>\n"
} else if schemeInfo.Recommended {
s += "<li><code>" + schemeInfo.Name + "</code></li>\n"
} else {
s += "<li><code class=\"meh\">" + schemeInfo.Name +
" (not recommended)</code></li>\n"
}
}
s += "</ul>\n"
return htmlTemplate.HTML(s)
}
func formatJA3(ja3 string) string {
titleArr := map[int]string{
0: "TLS version",
1: "cipher suites",
2: "extensions",
3: "supported groups",
4: "supported point formats",
}
sections := strings.Split(ja3, ",")
for i, s := range sections {
s = strings.Join(strings.Split(s, "-"), "-<wbr/>")
sections[i] = "<span title=\"" + titleArr[i] + "\" class=\"ja3\">" + s + "</span>"
}
return strings.Join(sections, ",<wbr/>")
}
func formatNJA3v1(nja3v1 string) string {
titleArr := map[int]string{
0: "TLS version (record header)",
1: "TLS version (handshake)",
2: "cipher suites",
3: "extensions (sorted, conditional extensions ignored)",
4: "supported groups",
5: "supported point formats",
6: "supported TLS versions",
7: "signature algorithms",
8: "pre-shared key exchange modes",
9: "certificate compression algorithms",
}
sections := strings.Split(nja3v1, ",")
for i, s := range sections {
s = strings.Join(strings.Split(s, "-"), "-<wbr/>")
sections[i] = "<span title=\"" + titleArr[i] + "\" class=\"ja3\">" + s + "</span>"
}
return strings.Join(sections, ",<wbr/>")
}
// === Gemtext functions ===
func getTLSVersionGemtext(v clienthello.TLSVersion) string {
var s string
vInfo := clienthello.GetTLSVersionInfo(v.(uint16), true)
if vInfo.Name == "GREASE" {
s = "0x" + vInfo.HexCode + " (GREASE)"
} else {
s = vInfo.Name
if v.(uint16) < 771 { // < TLSv1.2
s += " (deprecated)"
}
}
return s
}
func getTLSVersionsGemtext(versions []clienthello.TLSVersion) string {
var s string
for _, v := range versions {
s += "* " + getTLSVersionGemtext(v.(uint16)) + "\n"
}
return strings.TrimSuffix(s, "\n")
}
func getCipherSuiteGemtext(cs clienthello.CipherSuite, link bool) string {
var s string
csInfo := clienthello.GetCipherSuiteInfo(cs.(uint16), true)
if csInfo.Name == "GREASE" {
s = "0x" + csInfo.HexCode + " (GREASE)"
} else {
if link {
s = "=> https://ciphersuite.info/cs/" + csInfo.Name + "/ "
}
s += csInfo.Name
if !csInfo.Recommended {
s += " (not recommended)"
}
}
return s
}
func getCipherSuitesGemtext(suites []clienthello.CipherSuite) string {
var s string
for _, cs := range suites {
s += getCipherSuiteGemtext(cs.(uint16), true) + "\n"
}
return strings.TrimSuffix(s, "\n")
}
func getExtensionsGemtext(extensions []clienthello.Extension) string {
var s string
for _, ext := range extensions {
if ext.Name == "GREASE" {
s += "* 0x" + toHex(ext.Code) + " (GREASE)\n"
} else {
s += "* " + ext.Name + "\n"
}
}
return strings.TrimSuffix(s, "\n")
}
func getSupportedGroupsGemtext(groups []clienthello.NamedGroup) string {
var s string
for _, g := range groups {
gInfo := clienthello.GetNamedGroupInfo(g.(uint16), true)
if gInfo.Name == "GREASE" {
s += "* 0x" + gInfo.HexCode + " (GREASE)\n"
} else {
s += "* " + gInfo.Name + "\n"
}
}
return strings.TrimSuffix(s, "\n")
}
//func getSupportedPointFormatsGemtext(pFormats []clienthello.ECPointFormat) string {
// var s string
// for _, pf := range pFormats {
// s += "* " + clienthello.GetECPointFormatInfo(pf.(uint8), true).Name + "\n"
// }
// return strings.TrimSuffix(s, "\n")
//}
func getSignatureSchemesGemtext(sigSchemes []clienthello.SignatureScheme) string {
var s string
for _, ss := range sigSchemes {
schemeInfo := clienthello.GetSignatureSchemeInfo(ss.(uint16), true)
if schemeInfo.Name == "GREASE" {
s += "* 0x" + schemeInfo.HexCode + " (GREASE)"
} else {
s += "* " + schemeInfo.Name
if !schemeInfo.Recommended {
s += " (not recommended)"
}
}
s += "\n"
}
return strings.TrimSuffix(s, "\n")
}

2
go.mod
View File

@ -6,4 +6,4 @@ module tildegit.org/nervuri/client-hello-mirror
go 1.19
require golang.org/x/crypto v0.5.0
require golang.org/x/crypto v0.13.0

4
go.sum
View File

@ -1,2 +1,2 @@
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

View File

@ -1,23 +1,59 @@
# TLS Client Hello Mirror
## Your browser's TLS Client Hello, reflected as JSON
This service reflects your browser's TLS Client Hello message in multiple forms. It can be used directly or in CI tests to check for TLS privacy pitfalls (session resumption, fingerprinting, system time exposure) and security shortcommings (deprecated TLS versions, weak cipher suites, missing features, etc).
This test:
=> https://tildegit.org/nervuri/client-hello-mirror#tls-client-hello-mirror Details here
* 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.
## Endpoints
## API endpoints
=> /json/v1 json/v1 - basic
=> /json/v2 json/v2 - detailed
JSON only, for now. The API is largely stable - fields may be added, but existing fields will not be modified or removed. See the documentation for details:
=> https://tildegit.org/nervuri/client-hello-mirror/src/branch/master/DOC.md API Documentation
=> https://tildegit.org/nervuri/client-hello-mirror/src/branch/master/DOC.md API documentation
## Connection
* TLS version: {{.TLSVersion}}
* Cipher suite: {{.CipherSuite}}
* TLS session resumed: {{.SessionResumed}}{{.SessionResumptionInfo}}
{{.SystemTimeExposed}}
## Supported features
* Signed certificate timestamps: {{.SCTSupport}}
* OCSP stapling: {{.OCSPStaplingSupport}}
## Supported TLS/SSL versions
{{.SupportedTLSVersions}}
## Cipher suites
{{.SupportedCipherSuites}}
## Extensions
{{.Extensions}}
## Supported groups
{{.SupportedGroups}}
## Signature algorithms
{{.SignatureSchemes}}
## TLS fingerprint
* JA3: {{.JA3}}
* JA3 MD5: {{.JA3MD5}}
* NJA3v1: {{.NJA3v1}}
* NJA3v1 SHA256/128: {{.NJA3v1Hash}}
Parameters in the Client Hello message differ between clients, likely enabling servers and on-path observers to detect what browser you are likely using (down to its version, or a range of versions) by deriving its fingerprint from said parameters. JA3 is a simple and popular type of TLS fingerprint. NJA3 is a similar style of fingerprint which aims to improve the robustness and accuracy of JA3.
=> https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967/ TLS Fingerprinting with JA3 and JA3S
=> https://tildegit.org/nervuri/client-hello-mirror/src/branch/master/NJA3.md NJA3 documentation
_____________________
=> https://nervuri.net/ Author: nervuri
=> https://tildegit.org/nervuri/client-hello-mirror Source (contributions welcome)

View File

@ -9,63 +9,116 @@ SPDX-License-Identifier: BSD-3-Clause
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<!--Include empty favicon as base64 in order to prevent the extra network request.-->
<link rel="icon" type="image/ico" href="data:image/ico;base64,bQ=="/>
<meta name="theme-color" content="#000"/>
<meta name="referrer" content="no-referrer"/>
<title>TLS Client Hello Mirror</title>
<style>
:root {
color-scheme: dark;
}
::selection {
color: #FFF;
background-color: #070;
}
body {
color: #DDD;
background-color: #000;
margin: 1em auto;
max-width: 38em;
padding: 0 .62em;
font: 1.1em/1.62 sans-serif;
tab-size: 4;
}
@media print{
body{
max-width: none;
}
}
h1 {
text-align: center;
}
a:link {color:#EEE;}
a:visited {color:#EEE;}
a:hover {color:#FFF;}
a:active {color:#FFF;}
</style>
<style>{{.CSS}}</style>
</head>
<body>
<main>
<h1>TLS Client Hello Mirror</h1>
<h3>Your browser's TLS Client Hello, reflected as JSON</h3>
<p>This test:</p>
<p>This service reflects your browser's TLS <a href="https://tls13.xargs.org/#client-hello" target="_blank">Client Hello</a> message in multiple forms. It can be used directly or in CI tests to check for TLS privacy pitfalls (<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" target="_blank">session resumption</a>, <a href="https://tlsfingerprint.io/" target="_blank">fingerprinting</a>, <a href="https://datatracker.ietf.org/doc/html/draft-mathewson-no-gmtunixtime" target="_blank">system time exposure</a>) and security shortcommings (deprecated TLS versions, weak cipher suites, missing features, etc). <a href="https://tildegit.org/nervuri/client-hello-mirror#tls-client-hello-mirror" target="_blank">Details here</a>.</p>
<h3>API endpoints</h3>
<ul>
<li>reflects the complete Client Hello message, preserving the order in which TLS parameters and extensions are sent;</li>
<li>can be used to check for TLS privacy pitfalls (<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">session resumption</a>, <a href="https://tlsfingerprint.io/">TLS fingerprinting</a>, <a href="https://datatracker.ietf.org/doc/html/draft-mathewson-no-gmtunixtime">system time exposure</a>);</li>
<li>supports both HTTP and <a href="https://gemini.circumlunar.space/">Gemini</a>;</li>
<li>is <a href="https://www.gnu.org/philosophy/free-sw.en.html">free as in freedom</a> and trivial to self-host.</li>
<li><a href="/json/v1" target="_blank">json/v1 - basic</a></li>
<li><a href="/json/v2" target="_blank">json/v2 - detailed</a></li>
</ul>
<h3>Endpoints</h3>
<ul>
<li><a href="/json/v1">json/v1 - basic</a></li>
<li><a href="/json/v2">json/v2 - detailed</a></li>
</ul>
<p>JSON only, for now. The API is largely stable - fields may be added, but existing fields will not be modified or removed. See <a href="https://tildegit.org/nervuri/client-hello-mirror/src/branch/master/DOC.md">the documentation</a> for details.</p>
<p><a href="https://tildegit.org/nervuri/client-hello-mirror/src/branch/master/DOC.md" target="_blank">API documentation</a></p>
<h3 id="connection">
<a href="#connection">Connection</a>
</h3>
<fieldset>
<ul>
<li><b>TLS version:</b> <code>{{.TLSVersion}}</code></li>
<li><b>Cipher suite:</b> <code>{{.CipherSuite}}</code></li>
<li><b>TLS session resumed:</b> <code>{{.SessionResumed}}</code></li>
</ul>
{{.SessionResumptionInfo}}
</fieldset>
<h3 id="ch">
<a href="#ch">Client Hello</a>
</h3>
<div id="systime"><noscript>{{.SystemTimeExposedNoJS}}</noscript></div>
<fieldset>
<legend id="features">
<a href="#features">Supported features</a>
</legend>
<ul>
<li><b>Signed certificate timestamps:</b> <code>{{.SCTSupport}}</code></li>
<li><b>OCSP stapling:</b> <code>{{.OCSPStaplingSupport}}</code></li>
</ul>
{{.FeaturesInfo}}
</fieldset>
<br/>
<fieldset>
<legend id="versions">
<a href="#versions">Supported TLS/SSL versions</a>
</legend>
{{.SupportedTLSVersions}}
</fieldset>
<br/>
<fieldset>
<legend id="cipher-suites">
<a href="#cipher-suites">Cipher suites</a>
</legend>
{{.SupportedCipherSuites}}
</fieldset>
<br/>
<fieldset>
<legend id="extensions">
<a href="#extensions">Extensions</a>
</legend>
{{.Extensions}}
</fieldset>
<br/>
<fieldset>
<legend id="supported-groups">
<a href="#supported-groups">Supported groups</a>
</legend>
{{.SupportedGroups}}
</fieldset>
<br/>
<fieldset>
<legend id="signature-algorithms">
<a href="#signature-algorithms">Signature algorithms</a>
</legend>
{{.SignatureSchemes}}
</fieldset>
<br/>
<fieldset>
<legend id="fingerprint">
<a href="#fingerprint">TLS fingerprint</a>
</legend>
<ul>
<li><b>JA3:</b> <code>{{.JA3}}</code></li>
<li><b>JA3 MD5:</b> <code class="hash">{{.JA3MD5}}</code></li>
</ul>
<ul>
<li><b>NJA3v1:</b> <code>{{.NJA3v1}}</code></li>
<li><b>NJA3v1 SHA256/128:</b> <code class="hash">{{.NJA3v1Hash}}</code></li>
</ul>
<p>Parameters in the Client Hello message differ between clients, enabling servers and on-path observers to detect what browser you are likely using (down to its version, or a range of versions) by deriving its fingerprint from said parameters. JA3 is a simple and popular type of TLS fingerprint. NJA3 is a similar style of fingerprint which aims to improve the robustness and accuracy of JA3.</p>
<ul>
<li><a href="https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967/" target="_blank">TLS Fingerprinting with JA3 and JA3S</a></li>
<li><a href="https://tildegit.org/nervuri/client-hello-mirror/src/branch/master/NJA3.md" target="_blank">NJA3 documentation</a></li>
</ul>
</fieldset>
</main>
<hr/>
<footer>
Author: <a href="https://nervuri.net/">nervuri</a><br/>
<a href="https://tildegit.org/nervuri/client-hello-mirror">Source</a> (contributions welcome)<br/>
License: <a href="https://opensource.org/license/BSD-3-clause/">BSD-3-Clause</a>
Author: <a href="https://nervuri.net/" target="_blank">nervuri</a><br/>
<a href="https://tildegit.org/nervuri/client-hello-mirror" target="_blank">Source</a> (contributions welcome)<br/>
License: <a href="https://opensource.org/license/BSD-3-clause/" target="_blank">BSD-3-Clause</a>
</footer>
<script>{{.JS}}</script>
</body>
</html>

View File

@ -14,6 +14,7 @@ type request struct {
Protocol int
HTTPMethod int
Path string
Query string
}
// Supported protocols
@ -32,14 +33,10 @@ var ErrNotSupported = errors.New("Unsupported protocol or HTTP method")
// Get request information from the first line of the request.
func getRequestInfo(firstLine string) (req request, err error) {
var urlStr string // the string to be passed to url.Parse()
if strings.HasPrefix(firstLine, "gemini://") {
req.Protocol = geminiProtocol
var u *url.URL
u, err = url.Parse(firstLine)
if err != nil {
return
}
req.Path = u.Path
urlStr = firstLine
} else if strings.HasPrefix(firstLine, "GET ") {
req.HTTPMethod = getMethod
} else if strings.HasPrefix(firstLine, "HEAD ") {
@ -50,8 +47,15 @@ func getRequestInfo(firstLine string) (req request, err error) {
}
if req.HTTPMethod != 0 {
req.Protocol = httpProtocol
req.Path = strings.Split(firstLine, " ")[1]
urlStr = strings.Split(firstLine, " ")[1]
}
var u *url.URL
u, err = url.Parse(urlStr)
if err != nil {
return
}
req.Path = u.Path
req.Query = u.RawQuery
if req.Path == "" {
req.Path = "/"
}

11
script.js Normal file
View File

@ -0,0 +1,11 @@
// Client-side check to see if the Client Hello's gmt_unix_time
// corresponds to the system time. More accurate than the
// server-side check.
// The connection may have been slow, so allow gmt_unix_time to
// be 60 seconds in the past.
var gmtUnixTime = {{.GMTUnixTime}}
var now = Math.round(Date.now() / 1000)
if (now - 60 < gmtUnixTime && gmtUnixTime < now + 2) {
document.getElementById('systime').innerHTML = '{{.SystemTimeExposedJS}}'
}

277
server.go
View File

@ -10,12 +10,15 @@ import (
"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"
@ -54,6 +57,15 @@ func fatalError(err ...any) {
//go:embed index.html
var html string
//go:embed error.html
var htmlError string
//go:embed style.css
var css string
//go:embed script.js
var js string
//go:embed index.gmi
var gemtext string
@ -138,9 +150,26 @@ func requestHandler(conn *tls.Conn, rawClientHello []byte) {
// Parse Client Hello message.
var clientHelloMsg clienthello.ClientHelloMsg
if !clientHelloMsg.Unmarshal(rawClientHello) {
log.Println("Failed to parse Client Hello")
return
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.
@ -156,39 +185,237 @@ func requestHandler(conn *tls.Conn, rawClientHello []byte) {
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()
resp.Body = html
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
JS htmlTemplate.JS
}{
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),
htmlTemplate.JS(js),
})
if err != nil {
log.Println(err)
return
}
resp.Body = mainHTML.String()
}
} else if req.Protocol == geminiProtocol {
resp.Body = gemtext
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 {
resp.StatusLine = "20 application/json"
if inputErr != "" {
resp.StatusLine = "59 " + inputErr
} else {
resp.StatusLine = "20 application/json"
}
}
if req.Path == "/json/v2" {
clientHelloMsg.AddInfo()
tlsConnInfo.TLSVersion = clienthello.GetTLSVersionInfo(
tlsConnInfo.TLSVersion.(uint16))
tlsConnInfo.CipherSuite = clienthello.GetCipherSuiteInfo(
tlsConnInfo.CipherSuite.(uint16))
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)
}
output := struct {
ClientHello clienthello.ClientHelloMsg `json:"client_hello"`
TLSConnectionInfo tlsConnectionInfo `json:"connection_info"`
}{
clientHelloMsg,
tlsConnInfo,
}
outputJSON, err := json.MarshalIndent(output, "", " ")
if err != nil {
log.Println(err)
return
}
resp.Body = string(outputJSON)
} else {
if req.Protocol == httpProtocol {
resp.StatusLine = "HTTP/1.1 404 Not Found"

49
style.css Normal file
View File

@ -0,0 +1,49 @@
:root {
color-scheme: dark;
}
::selection {
color: #FFF;
background-color: #050;
}
body {
color: #DDD;
background-color: #000;
margin: 1em auto;
max-width: 38em;
padding: 0 .62em;
font: 1.1em/1.62 sans-serif;
overflow-wrap: break-word;
text-rendering: optimizeLegibility;
tab-size: 4;
}
@media print{
body{
max-width: none;
}
}
h1 {
text-align: center;
}
code {
font-family: monospace, sans-serif;
font-size: 0.9em;
line-height: 1.1;
font-weight: bold;
}
legend {
font-weight: bold;
}
a { color: #EEE; }
a:hover { color: lightblue; }
h1 a, h3 a, legend a { text-decoration: none; }
.good { color: #00C900; }
.meh { color: #FFC242; }
.bad { color: red; }
.dim { color: grey; }
.ja3:hover { color: #00C900; }
.hash { overflow-wrap: anywhere; }