Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,34 @@ Chrome. The `native` rollback profile disables this client image.
The H2 client separately uses the fixed `chrome-133` uTLS ClientHello profile.
That describes only its TLS ClientHello; H2 settings, header order, flow control,
connection reuse, payload sizes and timing retain their implementation behavior.
Source builds after v1.0.1 also support ordinary TLS 1.3 session resumption for
this profile when the caller enables a TLS session cache. An empty cache keeps
the fixed cold ClientHello shape; a valid cached ticket adds uTLS's native
`pre_shared_key` extension and binder as the last extension, as required by
[RFC 8446 section 4.2.11](https://www.rfc-editor.org/rfc/rfc8446.html#section-4.2.11).
The cache is private to one configured H2 client. A nil caller cache or
`SessionTicketsDisabled` keeps full handshakes. This does not enable 0-RTT:
every new physical connection completes TLS and starts fresh proxy authentication,
even when TLS resumes; connection-scoped proxy tickets are never inherited.
The pinned uTLS implementation cannot rebuild a populated PSK after a TLS 1.3
HelloRetryRequest. For this narrowly recognized library limitation, the H2 client
closes the failed socket and retries once on a fresh connection without a ticket,
within the same remaining initialization timeout. Certificate/hostname checks,
TLS 1.3 and h2 are still mandatory; unrelated TLS failures are not retried.
The library may invalidate the failed cached ticket. This compatibility fallback
is a full handshake, not successful HRR resumption or a browser-equivalence claim.

Resumption retains the previously verified TLS session rather than repeating a
full certificate exchange or calling `VerifyPeerCertificate` again. Callers
requiring fresh per-connection certificate policy must disable session tickets;
the native profile additionally supports `VerifyConnection`. Cached tickets are
not an unconditional privacy improvement: reusing a ticket can let passive
observers correlate connections
([RFC 8446 appendix C.4](https://www.rfc-editor.org/rfc/rfc8446.html#appendix-C.4)).
The bounded loopback regression verifies actual client/server resumption,
cold/warm ClientHello policy, and fresh authentication. It is not a browser
comparison or evidence of lower classifier accuracy.

Authenticated H2 and H3 CONNECT requests explicitly suppress the Go HTTP
libraries' default `User-Agent` and automatic `Accept-Encoding: gzip` values.
AutoCAR does not invent browser headers until the complete request-header set
Expand Down
84 changes: 83 additions & 1 deletion internal/tunnel/web_fingerprint.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package tunnel

import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"

utls "github.com/refraction-networking/utls"
)
Expand Down Expand Up @@ -74,6 +77,37 @@ type webH2TLSClientConn interface {
ConnectionState() tls.ConnectionState
}

var errWebH2PSKHelloRetryRequest = errors.New("tunnel: cached H2 TLS session requires an unsupported HelloRetryRequest")

// This diagnostic is private to the pinned uTLS v1.8.2 implementation. It has
// no exported error type; recognize its exact text AND completed HRR state,
// never arbitrary certificate, entropy or I/O errors with similar text.
const webH2UnsupportedPSKHRR = "uTLS does not support reprocessing of PSK key triggered by HelloRetryRequest"

func webH2UnsupportedPSKHelloRetryRequest(err error, state utls.PubClientHandshakeState) bool {
if err == nil || err.Error() != webH2UnsupportedPSKHRR || state.Hello == nil || state.ServerHello == nil {
return false
}
hello, server := state.Hello, state.ServerHello
hrrRandom := [...]byte{ // RFC 8446 section 4.1.3.
0xcf, 0x21, 0xad, 0x74, 0xe5, 0x9a, 0x61, 0x11,
0xbe, 0x1d, 0x8c, 0x02, 0x1e, 0x65, 0xb8, 0x91,
0xc2, 0xa2, 0x11, 0x16, 0x7a, 0xbb, 0x8c, 0x5e,
0x07, 0x9e, 0x09, 0xe2, 0xc8, 0xa8, 0x33, 0x9c,
}
if server.SupportedVersion != utls.VersionTLS13 || !bytes.Equal(server.Random, hrrRandom[:]) ||
len(hello.PskIdentities) == 0 || len(hello.PskBinders) != len(hello.PskIdentities) {
return false
}
if server.SelectedGroup != 0 {
// uTLS reaches the unsupported-PSK diagnostic only after generating
// and installing the requested share. This excludes an entropy error
// with the same text during HRR key generation.
return len(hello.KeyShares) == 1 && hello.KeyShares[0].Group == server.SelectedGroup
}
return len(server.Cookie) > 0
}

func newWebH2TLSClientConn(raw net.Conn, config *tls.Config, profile FingerprintProfile, sessionCache utls.ClientSessionCache) (webH2TLSClientConn, error) {
if raw == nil {
return nil, errors.New("tunnel: nil web-cover HTTP/2 TCP connection")
Expand All @@ -87,7 +121,18 @@ func newWebH2TLSClientConn(raw net.Conn, config *tls.Config, profile Fingerprint
if err != nil {
return nil, err
}
return &webH2UTLSConn{UConn: utls.UClient(raw, utlsConfig, utls.HelloChrome_133)}, nil
// The fixed cold profile has no pre_shared_key extension. Use a fresh
// custom copy when resumption is enabled so uTLS can populate its own
// PSK identity and binder, without changing the empty-cache wire shape.
resume := utlsConfig.ClientSessionCache != nil && !utlsConfig.SessionTicketsDisabled
hello := utls.HelloChrome_133
if resume {
hello = utls.HelloCustom
}
return &webH2UTLSConn{
UConn: utls.UClient(raw, utlsConfig, hello),
prepareChrome133: resume,
}, nil
case FingerprintNative:
return tls.Client(raw, config.Clone()), nil
default:
Expand Down Expand Up @@ -135,6 +180,7 @@ func chrome133UTLSConfig(input *tls.Config, sessionCache utls.ClientSessionCache
MaxVersion: utls.VersionTLS13,
SessionTicketsDisabled: input.SessionTicketsDisabled,
ClientSessionCache: sessionCache,
OmitEmptyPsk: true,
DynamicRecordSizingDisabled: input.DynamicRecordSizingDisabled,
KeyLogWriter: input.KeyLogWriter,
}, nil
Expand Down Expand Up @@ -176,6 +222,10 @@ func cloneCertificateForUTLS(input tls.Certificate) utls.Certificate {

type webH2UTLSConn struct {
*utls.UConn
prepareChrome133 bool
prepareOnce sync.Once
prepareErr error
handshakeComplete atomic.Bool
}

// HandshakeContext enforces the application's TLS 1.3-only policy after the
Expand All @@ -185,12 +235,44 @@ type webH2UTLSConn struct {
// without ceasing to be that profile. Failing before any HTTP bytes are sent
// keeps the policy fail-closed even if a future caller forgets a second check.
func (c *webH2UTLSConn) HandshakeContext(ctx context.Context) error {
if c.prepareChrome133 {
if !c.handshakeComplete.Load() {
if err := ctx.Err(); err != nil {
return err
}
}
c.prepareOnce.Do(func() {
// ApplyPreset generates entropy and key shares: keep it inside the
// caller's handshake budget rather than in the connection factory.
// Each connection owns the extension pointers mutated by uTLS.
spec, err := utls.UTLSIdToSpec(utls.HelloChrome_133)
if err == nil {
// TLS 1.3 requires this extension to be last. OmitEmptyPsk
// suppresses it until a valid cached session is available.
spec.Extensions = append(spec.Extensions, &utls.UtlsPreSharedKeyExtension{})
Comment thread
cppla marked this conversation as resolved.
err = c.UConn.ApplyPreset(&spec)
}
c.prepareErr = err
})
if c.prepareErr != nil {
return c.prepareErr
}
if !c.handshakeComplete.Load() {
if err := ctx.Err(); err != nil {
return err
}
}
}
if err := c.UConn.HandshakeContext(ctx); err != nil {
if c.prepareChrome133 && err.Error() == webH2UnsupportedPSKHRR && webH2UnsupportedPSKHelloRetryRequest(err, c.UConn.HandshakeState) {
return fmt.Errorf("%w: %w", errWebH2PSKHelloRetryRequest, err)
}
return err
}
if c.UConn.ConnectionState().Version != utls.VersionTLS13 {
return errors.New("tunnel: web-cover HTTP/2 connection did not negotiate TLS 1.3")
}
c.handshakeComplete.Store(true)
return nil
}

Expand Down
Loading
Loading