diff --git a/cmd/probe/main.go b/cmd/probe/main.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net"
"net/http"
@@ -55,7 +56,7 @@ func dialUTLS(ctx context.Context, id utls.ClientHelloID) (*utls.UConn, error) {
return uconn, nil
}
-func decodeJA4(body interface{ Read([]byte) (int, error) }) (string, error) {
+func decodeJA4(body io.Reader) (string, error) {
var result struct {
JA4 string `json:"ja4"`
}
diff --git a/fingerprint/fingerprint.go b/fingerprint/fingerprint.go
@@ -1,15 +0,0 @@
-// Package fingerprint provides interfaces and implementations for controlling
-// outbound TLS connection fingerprints.
-package fingerprint
-
-import utls "github.com/bogdanfinn/utls"
-
-type Applier interface {
- Name() string
-}
-
-// TLSApplier controls the uTLS ClientHello fingerprint for outbound connections.
-type TLSApplier interface {
- Applier
- ClientHelloID() utls.ClientHelloID
-}
diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -5,7 +5,7 @@ import (
"encoding/binary"
"fmt"
"net"
- "sort"
+ "slices"
"strings"
)
@@ -27,33 +27,43 @@ func isGREASE(v uint16) bool {
return v&0x0f == 0x0a && v>>8 == v&0xff
}
-// ParseClientHello parses the first TLS handshake record from raw.
-// raw must start with a handshake record header (content type 0x16).
-func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
+// handshakeBody unwraps the TLS record and handshake headers from raw,
+// verifying the record content type, the handshake type, and length fields.
+// It returns the handshake body. tag prefixes errors ("ja4" or "ja4s").
+func handshakeBody(raw []byte, hsType byte, tag string) ([]byte, error) {
if len(raw) < 5 {
- return nil, fmt.Errorf("ja4: record too short (%d bytes)", len(raw))
+ return nil, fmt.Errorf("%s: record too short (%d bytes)", tag, len(raw))
}
if raw[0] != 0x16 {
- return nil, fmt.Errorf("ja4: not a handshake record (type=0x%02x)", raw[0])
+ return nil, fmt.Errorf("%s: not a handshake record (type=0x%02x)", tag, raw[0])
}
recLen := int(binary.BigEndian.Uint16(raw[3:5]))
if len(raw) < 5+recLen {
- return nil, fmt.Errorf("ja4: record body truncated (need %d, have %d)", 5+recLen, len(raw))
+ return nil, fmt.Errorf("%s: record body truncated (need %d, have %d)", tag, 5+recLen, len(raw))
}
hs := raw[5 : 5+recLen]
if len(hs) < 4 {
- return nil, fmt.Errorf("ja4: handshake header too short")
+ return nil, fmt.Errorf("%s: handshake header too short", tag)
}
- if hs[0] != 0x01 {
- return nil, fmt.Errorf("ja4: not a ClientHello (handshake type=0x%02x)", hs[0])
+ if hs[0] != hsType {
+ return nil, fmt.Errorf("%s: unexpected handshake type 0x%02x (want 0x%02x)", tag, hs[0], hsType)
}
// Handshake length is 3 bytes, unlike most other TLS length fields.
- chLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
- if len(hs) < 4+chLen {
- return nil, fmt.Errorf("ja4: ClientHello body truncated")
+ bodyLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
+ if len(hs) < 4+bodyLen {
+ return nil, fmt.Errorf("%s: handshake body truncated", tag)
+ }
+ return hs[4 : 4+bodyLen], nil
+}
+
+// ParseClientHello parses the first TLS handshake record from raw.
+// raw must start with a handshake record header (content type 0x16).
+func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
+ r, err := handshakeBody(raw, 0x01, "ja4")
+ if err != nil {
+ return nil, err
}
- r := hs[4 : 4+chLen]
fields := &ClientHelloFields{}
@@ -232,13 +242,7 @@ func ComputeJA4(ch *ClientHelloFields) string {
func buildJA4a(ch *ClientHelloFields) string {
tlsVer := tlsVersionStr(ch.LegacyVersion)
if len(ch.SupportedVersions) > 0 {
- max := uint16(0)
- for _, v := range ch.SupportedVersions {
- if v > max {
- max = v
- }
- }
- tlsVer = tlsVersionStr(max)
+ tlsVer = tlsVersionStr(slices.Max(ch.SupportedVersions))
}
sniChar := "n"
@@ -291,9 +295,8 @@ func tlsVersionStr(v uint16) string {
}
func buildJA4b(ciphers []uint16) string {
- sorted := make([]uint16, len(ciphers))
- copy(sorted, ciphers)
- sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
+ sorted := slices.Clone(ciphers)
+ slices.Sort(sorted)
parts := make([]string, len(sorted))
for i, c := range sorted {
@@ -313,7 +316,7 @@ func buildJA4c(exts []uint16, sigAlgs []uint16) string {
filtered = append(filtered, e)
}
}
- sort.Slice(filtered, func(i, j int) bool { return filtered[i] < filtered[j] })
+ slices.Sort(filtered)
extParts := make([]string, len(filtered))
for i, e := range filtered {
diff --git a/fingerprint/ja4_test.go b/fingerprint/ja4_test.go
@@ -0,0 +1,229 @@
+package fingerprint
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/binary"
+ "fmt"
+ "testing"
+)
+
+// buildClientHello synthesizes a TLS handshake record carrying a ClientHello,
+// returned as raw bytes ready for ParseClientHello. extensions is the
+// wire-encoded extensions block (use ext() + the *Data helpers below).
+func buildClientHello(legacyVer uint16, ciphers []uint16, extensions []byte) []byte {
+ var body bytes.Buffer
+
+ binary.Write(&body, binary.BigEndian, legacyVer)
+ body.Write(make([]byte, 32)) // random
+ body.WriteByte(0x00) // session_id length = 0
+
+ binary.Write(&body, binary.BigEndian, uint16(len(ciphers)*2))
+ for _, c := range ciphers {
+ binary.Write(&body, binary.BigEndian, c)
+ }
+
+ body.WriteByte(0x01) // compression_methods length
+ body.WriteByte(0x00) // null
+
+ binary.Write(&body, binary.BigEndian, uint16(len(extensions)))
+ body.Write(extensions)
+
+ hsLen := body.Len()
+ hs := []byte{0x01, byte(hsLen >> 16), byte(hsLen >> 8), byte(hsLen)}
+ hs = append(hs, body.Bytes()...)
+
+ rec := []byte{0x16, 0x03, 0x01, byte(len(hs) >> 8), byte(len(hs))}
+ return append(rec, hs...)
+}
+
+func sniData(host string) []byte {
+ name := []byte(host)
+ entry := append([]byte{0x00, byte(len(name) >> 8), byte(len(name))}, name...)
+ return append([]byte{byte(len(entry) >> 8), byte(len(entry))}, entry...)
+}
+
+func alpnData(protos ...string) []byte {
+ var list []byte
+ for _, p := range protos {
+ list = append(list, byte(len(p)))
+ list = append(list, p...)
+ }
+ return append([]byte{byte(len(list) >> 8), byte(len(list))}, list...)
+}
+
+// clientSuppVerData uses the ClientHello 1-byte list length (the ServerHello
+// variant is a bare uint16).
+func clientSuppVerData(vers ...uint16) []byte {
+ out := []byte{byte(len(vers) * 2)}
+ for _, v := range vers {
+ out = append(out, byte(v>>8), byte(v))
+ }
+ return out
+}
+
+func sigAlgsData(algs ...uint16) []byte {
+ out := []byte{byte(len(algs) * 2 >> 8), byte(len(algs) * 2)}
+ for _, a := range algs {
+ out = append(out, byte(a>>8), byte(a))
+ }
+ return out
+}
+
+func realisticClientHello() []byte {
+ var exts []byte
+ exts = append(exts, ext(0x0000, sniData("example.com"))...)
+ exts = append(exts, ext(0x002b, clientSuppVerData(0x0304))...)
+ exts = append(exts, ext(0x0010, alpnData("h2"))...)
+ exts = append(exts, ext(0x000d, sigAlgsData(0x0403, 0x0804))...)
+ exts = append(exts, ext(0x0033, []byte{})...) // key_share, empty
+ return buildClientHello(0x0303, []uint16{0x1301, 0x1302, 0x1303}, exts)
+}
+
+func TestParseClientHello_Fields(t *testing.T) {
+ ch, err := ParseClientHello(realisticClientHello())
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ if ch.LegacyVersion != 0x0303 {
+ t.Errorf("legacy version = 0x%04x; want 0x0303", ch.LegacyVersion)
+ }
+ if want := []uint16{0x1301, 0x1302, 0x1303}; !equalU16(ch.CipherSuites, want) {
+ t.Errorf("ciphers = %v; want %v", ch.CipherSuites, want)
+ }
+ if want := []uint16{0x0000, 0x002b, 0x0010, 0x000d, 0x0033}; !equalU16(ch.Extensions, want) {
+ t.Errorf("extensions = %v; want %v", ch.Extensions, want)
+ }
+ if ch.SNIHost != "example.com" || ch.SNIIsIP {
+ t.Errorf("SNI = %q (ip=%v); want example.com (ip=false)", ch.SNIHost, ch.SNIIsIP)
+ }
+ if want := []uint16{0x0304}; !equalU16(ch.SupportedVersions, want) {
+ t.Errorf("supported versions = %v; want %v", ch.SupportedVersions, want)
+ }
+ if len(ch.ALPNValues) != 1 || ch.ALPNValues[0] != "h2" {
+ t.Errorf("alpn = %v; want [h2]", ch.ALPNValues)
+ }
+ if want := []uint16{0x0403, 0x0804}; !equalU16(ch.SigAlgs, want) {
+ t.Errorf("sig algs = %v; want %v", ch.SigAlgs, want)
+ }
+}
+
+func TestParseClientHello_SNI_IP(t *testing.T) {
+ exts := ext(0x0000, sniData("192.0.2.1"))
+ ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ if ch.SNIHost != "192.0.2.1" || !ch.SNIIsIP {
+ t.Errorf("SNI = %q (ip=%v); want 192.0.2.1 (ip=true)", ch.SNIHost, ch.SNIIsIP)
+ }
+}
+
+func TestParseClientHello_GREASEFiltered(t *testing.T) {
+ exts := ext(0x1a1a, []byte{}) // GREASE extension
+ exts = append(exts, ext(0x002b, clientSuppVerData(0x0304))...)
+ ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x0a0a, 0x1301}, exts))
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ if want := []uint16{0x1301}; !equalU16(ch.CipherSuites, want) {
+ t.Errorf("ciphers = %v; want %v (GREASE filtered)", ch.CipherSuites, want)
+ }
+ if want := []uint16{0x002b}; !equalU16(ch.Extensions, want) {
+ t.Errorf("extensions = %v; want %v (GREASE filtered)", ch.Extensions, want)
+ }
+}
+
+func TestParseClientHello_Errors(t *testing.T) {
+ cases := []struct {
+ name string
+ raw []byte
+ }{
+ {"empty", nil},
+ {"too short", []byte{0x16, 0x03}},
+ {"wrong content type", []byte{0x17, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00}},
+ {"wrong handshake type (ServerHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}},
+ {"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x01, 0x00, 0x00, 0x00}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := ParseClientHello(tc.raw); err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+ }
+}
+
+// TestComputeJA4_KnownFixture pins the JA4 of realisticClientHello(). JA4_a is
+// human-readable. The two hashes are checked against their spec preimages,
+// independently verifiable with: printf '%s' '<preimage>' | sha256sum.
+func TestComputeJA4_KnownFixture(t *testing.T) {
+ ch, err := ParseClientHello(realisticClientHello())
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ got := ComputeJA4(ch)
+
+ const wantA = "t13d0305h2" // tls1.3, domain SNI, 3 ciphers, 5 exts, alpn h2
+ wantB := sha256Hex12("1301,1302,1303")
+ wantC := sha256Hex12("000d,002b,0033_0403,0804") // exts sans SNI+ALPN, sorted; then sig algs
+
+ want := wantA + "_" + wantB + "_" + wantC
+ if got != want {
+ t.Errorf("JA4 = %q; want %q", got, want)
+ }
+}
+
+func TestComputeJA4_a_Indicators(t *testing.T) {
+ tests := []struct {
+ name string
+ legacyVer uint16
+ ciphers []uint16
+ exts []byte
+ wantPrefix string
+ }{
+ {
+ name: "no SNI, no versions ext, no alpn",
+ legacyVer: 0x0303,
+ ciphers: []uint16{0x1301, 0x1302},
+ exts: ext(0x0033, []byte{}),
+ wantPrefix: "t12n0201", // legacy 1.2, no SNI -> n, 2 ciphers, 1 ext, alpn 00
+ },
+ {
+ name: "supported_versions max wins over legacy",
+ legacyVer: 0x0301,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x002b, clientSuppVerData(0x0303, 0x0304)),
+ wantPrefix: "t13n0101", // max(1.2,1.3)=1.3
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ch, err := ParseClientHello(buildClientHello(tc.legacyVer, tc.ciphers, tc.exts))
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ a := ComputeJA4(ch)[:len(tc.wantPrefix)]
+ if a != tc.wantPrefix {
+ t.Errorf("JA4_a = %q; want %q", a, tc.wantPrefix)
+ }
+ })
+ }
+}
+
+func equalU16(a, b []uint16) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func sha256Hex12(s string) string {
+ h := sha256.Sum256([]byte(s))
+ return fmt.Sprintf("%x", h)[:12]
+}
diff --git a/fingerprint/ja4s.go b/fingerprint/ja4s.go
@@ -9,39 +9,20 @@ import (
// ServerHelloFields holds the parsed TLS ServerHello data used to compute JA4S.
type ServerHelloFields struct {
- LegacyVersion uint16
- CipherSuite uint16 // single chosen suite
- Extensions []uint16 // non-GREASE extension types, wire order
- SupportedVersions []uint16 // from ext 0x002b: in ServerHello, the single chosen version
- ALPNValues []string // from ext 0x0010: in ServerHello, the single chosen protocol
+ LegacyVersion uint16
+ CipherSuite uint16 // single chosen suite
+ Extensions []uint16 // non-GREASE extension types, wire order
+ SupportedVersion uint16 // from ext 0x002b, single chosen version (0 if absent)
+ ALPN string // from ext 0x0010, single chosen protocol ("" if absent)
}
// ParseServerHello parses the first TLS handshake record from raw, expecting
// a ServerHello. raw must start with a handshake record header (content type 0x16).
func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
- if len(raw) < 5 {
- return nil, fmt.Errorf("ja4s: record too short (%d bytes)", len(raw))
+ r, err := handshakeBody(raw, 0x02, "ja4s")
+ if err != nil {
+ return nil, err
}
- if raw[0] != 0x16 {
- return nil, fmt.Errorf("ja4s: not a handshake record (type=0x%02x)", raw[0])
- }
- recLen := int(binary.BigEndian.Uint16(raw[3:5]))
- if len(raw) < 5+recLen {
- return nil, fmt.Errorf("ja4s: record body truncated (need %d, have %d)", 5+recLen, len(raw))
- }
- hs := raw[5 : 5+recLen]
-
- if len(hs) < 4 {
- return nil, fmt.Errorf("ja4s: handshake header too short")
- }
- if hs[0] != 0x02 {
- return nil, fmt.Errorf("ja4s: not a ServerHello (handshake type=0x%02x)", hs[0])
- }
- shLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
- if len(hs) < 4+shLen {
- return nil, fmt.Errorf("ja4s: ServerHello body truncated")
- }
- r := hs[4 : 4+shLen]
fields := &ServerHelloFields{}
@@ -105,7 +86,11 @@ func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
switch extType {
case 0x002b:
- parseServerSupportedVersion(extData, fields)
+ if len(extData) >= 2 {
+ if v := binary.BigEndian.Uint16(extData[:2]); !isGREASE(v) {
+ fields.SupportedVersion = v
+ }
+ }
case 0x0010:
parseServerALPN(extData, fields)
}
@@ -114,75 +99,50 @@ func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
return fields, nil
}
-// parseServerSupportedVersion reads the single chosen TLS version (2 bytes).
-// Unlike the ClientHello variant which carries a list, the ServerHello variant
-// is a bare uint16.
-func parseServerSupportedVersion(data []byte, f *ServerHelloFields) {
- if len(data) < 2 {
- return
- }
- v := binary.BigEndian.Uint16(data[:2])
- if !isGREASE(v) {
- f.SupportedVersions = append(f.SupportedVersions, v)
- }
-}
-
-// parseServerALPN reads the ALPN extension as the server emits it. Wire format
-// is the same as in ClientHello, but the server only ever lists one protocol.
+// parseServerALPN reads the single protocol the server selected. Wire format
+// matches ClientHello, but the server lists exactly one.
func parseServerALPN(data []byte, f *ServerHelloFields) {
- if len(data) < 2 {
+ if len(data) < 3 {
return
}
listLen := int(binary.BigEndian.Uint16(data[:2]))
data = data[2:]
- if len(data) < listLen {
+ if len(data) < listLen || listLen < 1 {
return
}
- data = data[:listLen]
- for len(data) >= 1 {
- pLen := int(data[0])
- data = data[1:]
- if len(data) < pLen {
- break
- }
- f.ALPNValues = append(f.ALPNValues, string(data[:pLen]))
- data = data[pLen:]
+ pLen := int(data[0])
+ if 1+pLen > listLen || len(data) < 1+pLen {
+ return
}
+ f.ALPN = string(data[1 : 1+pLen])
}
// ComputeJA4S builds the JA4S hash from a parsed ServerHello.
// Format: t<version><nn><alpn>_<cipher>_<exthash>
// Always "t" for TCP; QUIC is not produced by mic.
func ComputeJA4S(sh *ServerHelloFields) string {
- return buildJA4Sa(sh) + "_" + buildJA4Sb(sh.CipherSuite) + "_" + buildJA4Sc(sh.Extensions)
+ return buildJA4Sa(sh) + "_" + fmt.Sprintf("%04x", sh.CipherSuite) + "_" + buildJA4Sc(sh.Extensions)
}
func buildJA4Sa(sh *ServerHelloFields) string {
ver := tlsVersionStr(sh.LegacyVersion)
- if len(sh.SupportedVersions) > 0 {
- ver = tlsVersionStr(sh.SupportedVersions[0])
+ if sh.SupportedVersion != 0 {
+ ver = tlsVersionStr(sh.SupportedVersion)
}
nn := min99(len(sh.Extensions))
alpn := "00"
- if len(sh.ALPNValues) > 0 {
- v := sh.ALPNValues[0]
- switch {
- case len(v) >= 2:
- alpn = v[:2]
- case len(v) == 1:
- alpn = v + "0"
- }
+ switch v := sh.ALPN; {
+ case len(v) >= 2:
+ alpn = v[:2]
+ case len(v) == 1:
+ alpn = v + "0"
}
return fmt.Sprintf("t%s%02d%s", ver, nn, alpn)
}
-func buildJA4Sb(cipher uint16) string {
- return fmt.Sprintf("%04x", cipher)
-}
-
// buildJA4Sc hashes ServerHello extension types in wire order (no sort).
// This is the difference from JA4_c, which sorts.
func buildJA4Sc(exts []uint16) string {
diff --git a/fingerprint/ja4s_test.go b/fingerprint/ja4s_test.go
@@ -65,11 +65,11 @@ func TestParseServerHello_TLS13_Minimal(t *testing.T) {
if got := len(sh.Extensions); got != 2 {
t.Errorf("extension count = %d; want 2", got)
}
- if len(sh.SupportedVersions) != 1 || sh.SupportedVersions[0] != 0x0304 {
- t.Errorf("supported versions = %v; want [0x0304]", sh.SupportedVersions)
+ if sh.SupportedVersion != 0x0304 {
+ t.Errorf("supported version = 0x%04x; want 0x0304", sh.SupportedVersion)
}
- if len(sh.ALPNValues) != 0 {
- t.Errorf("alpn = %v; want none", sh.ALPNValues)
+ if sh.ALPN != "" {
+ t.Errorf("alpn = %q; want none", sh.ALPN)
}
}
@@ -88,8 +88,8 @@ func TestParseServerHello_TLS13_WithALPN_h2(t *testing.T) {
if err != nil {
t.Fatalf("ParseServerHello: %v", err)
}
- if len(sh.ALPNValues) != 1 || sh.ALPNValues[0] != "h2" {
- t.Fatalf("alpn = %v; want [h2]", sh.ALPNValues)
+ if sh.ALPN != "h2" {
+ t.Fatalf("alpn = %q; want h2", sh.ALPN)
}
}
@@ -106,8 +106,8 @@ func TestParseServerHello_TLS12_NoSupportedVersions(t *testing.T) {
if sh.LegacyVersion != 0x0303 {
t.Errorf("legacy version = 0x%04x; want 0x0303", sh.LegacyVersion)
}
- if len(sh.SupportedVersions) != 0 {
- t.Errorf("supported_versions present in TLS 1.2 fixture: %v", sh.SupportedVersions)
+ if sh.SupportedVersion != 0 {
+ t.Errorf("supported_version present in TLS 1.2 fixture: 0x%04x", sh.SupportedVersion)
}
if sh.CipherSuite != 0xc02f {
t.Errorf("cipher = 0x%04x; want 0xc02f", sh.CipherSuite)
@@ -163,9 +163,8 @@ func TestComputeJA4S_Shape(t *testing.T) {
sh := &ServerHelloFields{
LegacyVersion: 0x0303,
CipherSuite: 0x1301,
- Extensions: []uint16{0x002b, 0x0033},
- SupportedVersions: []uint16{0x0304},
- ALPNValues: nil,
+ Extensions: []uint16{0x002b, 0x0033},
+ SupportedVersion: 0x0304,
}
got := ComputeJA4S(sh)
@@ -181,8 +180,8 @@ func TestComputeJA4S_KnownFixture(t *testing.T) {
sh := &ServerHelloFields{
LegacyVersion: 0x0303,
CipherSuite: 0x1301,
- Extensions: []uint16{0x002b, 0x0033},
- SupportedVersions: []uint16{0x0304},
+ Extensions: []uint16{0x002b, 0x0033},
+ SupportedVersion: 0x0304,
}
got := ComputeJA4S(sh)
const want = "t130200_1301_" // prefix; suffix is the 12-hex hash we pin below
@@ -200,9 +199,9 @@ func TestComputeJA4S_ALPN_h2(t *testing.T) {
sh := &ServerHelloFields{
LegacyVersion: 0x0303,
CipherSuite: 0x1301,
- Extensions: []uint16{0x002b, 0x0010, 0x0033},
- SupportedVersions: []uint16{0x0304},
- ALPNValues: []string{"h2"},
+ Extensions: []uint16{0x002b, 0x0010, 0x0033},
+ SupportedVersion: 0x0304,
+ ALPN: "h2",
}
got := ComputeJA4S(sh)
// nn=03, alpn=h2
diff --git a/fingerprint/tls.go b/fingerprint/tls.go
@@ -1,3 +1,5 @@
+// Package fingerprint provides implementations for controlling outbound TLS
+// connection fingerprints.
package fingerprint
import (
diff --git a/proxy/ca_test.go b/proxy/ca_test.go
@@ -0,0 +1,105 @@
+package proxy
+
+import (
+ "bytes"
+ "crypto/x509"
+ "net"
+ "testing"
+)
+
+func TestIssueCert_SignedChain_DNS(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+
+ cert, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert: %v", err)
+ }
+
+ if len(cert.Certificate) != 2 {
+ t.Fatalf("chain length = %d; want 2 (leaf + CA)", len(cert.Certificate))
+ }
+
+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
+ if err != nil {
+ t.Fatalf("parse leaf: %v", err)
+ }
+
+ roots := x509.NewCertPool()
+ roots.AddCert(ca.cert)
+ if _, err := leaf.Verify(x509.VerifyOptions{
+ DNSName: "example.com",
+ Roots: roots,
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }); err != nil {
+ t.Errorf("leaf does not verify against CA: %v", err)
+ }
+
+ if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "example.com" {
+ t.Errorf("DNSNames = %v; want [example.com]", leaf.DNSNames)
+ }
+ if len(leaf.IPAddresses) != 0 {
+ t.Errorf("IPAddresses = %v; want none for a hostname", leaf.IPAddresses)
+ }
+}
+
+func TestIssueCert_IP(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+
+ cert, err := ca.issueCert("192.0.2.1")
+ if err != nil {
+ t.Fatalf("issueCert: %v", err)
+ }
+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
+ if err != nil {
+ t.Fatalf("parse leaf: %v", err)
+ }
+
+ roots := x509.NewCertPool()
+ roots.AddCert(ca.cert)
+ if _, err := leaf.Verify(x509.VerifyOptions{
+ DNSName: "192.0.2.1",
+ Roots: roots,
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }); err != nil {
+ t.Errorf("leaf does not verify for IP SAN: %v", err)
+ }
+
+ if len(leaf.IPAddresses) != 1 || !leaf.IPAddresses[0].Equal(net.ParseIP("192.0.2.1")) {
+ t.Errorf("IPAddresses = %v; want [192.0.2.1]", leaf.IPAddresses)
+ }
+ if len(leaf.DNSNames) != 0 {
+ t.Errorf("DNSNames = %v; want none for an IP", leaf.DNSNames)
+ }
+}
+
+func TestIssueCert_Caches(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+
+ first, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert: %v", err)
+ }
+ second, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert (cached): %v", err)
+ }
+
+ if !bytes.Equal(first.Certificate[0], second.Certificate[0]) {
+ t.Error("second issueCert for same host returned a different cert; cache miss")
+ }
+ if _, err := ca.issueCert("other.com"); err != nil {
+ t.Fatalf("issueCert other host: %v", err)
+ }
+ if len(ca.cache) != 2 {
+ t.Errorf("cache size = %d; want 2", len(ca.cache))
+ }
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -17,7 +17,7 @@ type Proxy struct {
ListenAddr string
BackendAddr string // server-front: backend host:port
CAPool *x509.CertPool
- Fingerprint fingerprint.TLSApplier
+ Fingerprint *fingerprint.TLSFingerprint
LocalCA *LocalCA // client-front: MitM CA for TLS interception
Handler Handler
}
| | | | | | | | |