diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6fb71cdd4c1..dec51a06a2a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "windows-latest", "macos-latest"] - go: ["1.24.x"] + go: ["1.27.x"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/auth.go b/auth.go index 998deb9b21d..bc1f06063f2 100644 --- a/auth.go +++ b/auth.go @@ -10,6 +10,7 @@ import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" + "crypto/mldsa" "crypto/rsa" "errors" "fmt" @@ -37,6 +38,14 @@ func verifyHandshakeSignature(sigType uint8, pubkey crypto.PublicKey, hashFunc c if !ed25519.Verify(pubKey, signed, sig) { return errors.New("Ed25519 verification failure") } + case signatureMLDSA: + pubKey, ok := pubkey.(*mldsa.PublicKey) + if !ok { + return fmt.Errorf("expected an ML-DSA public key, got %T", pubkey) + } + if err := mldsa.Verify(pubKey, signed, sig, nil); err != nil { + return fmt.Errorf("ML-DSA verification failure: %w", err) + } case signaturePKCS1v15: pubKey, ok := pubkey.(*rsa.PublicKey) if !ok { @@ -105,6 +114,8 @@ func typeAndHashFromSignatureScheme(signatureAlgorithm SignatureScheme) (sigType sigType = signatureECDSA case Ed25519: sigType = signatureEd25519 + case MLDSA44, MLDSA65, MLDSA87: + sigType = signatureMLDSA default: return 0, 0, fmt.Errorf("unsupported signature algorithm: %v", signatureAlgorithm) } @@ -119,6 +130,8 @@ func typeAndHashFromSignatureScheme(signatureAlgorithm SignatureScheme) (sigType hash = crypto.SHA512 case Ed25519: hash = directSigning + case MLDSA44, MLDSA65, MLDSA87: + hash = directSigning default: return 0, 0, fmt.Errorf("unsupported signature algorithm: %v", signatureAlgorithm) } @@ -140,6 +153,8 @@ func legacyTypeAndHashFromPublicKey(pub crypto.PublicKey) (sigType uint8, hash c // full signature, and not even OpenSSL bothers with the // complexity, so we can't even test it properly. return 0, 0, fmt.Errorf("tls: Ed25519 public keys are not supported before TLS 1.2") + case *mldsa.PublicKey: + return 0, 0, fmt.Errorf("tls: ML-DSA public keys are not supported before TLS 1.3") default: return 0, 0, fmt.Errorf("tls: unsupported public key: %T", pub) } diff --git a/common.go b/common.go index 73b6dad51cb..3f9c7adcb39 100644 --- a/common.go +++ b/common.go @@ -207,6 +207,7 @@ const ( signatureECDSA signatureEd25519 signatureEdDilithium3 + signatureMLDSA ) // directSigning is a standard Hash value that signals that no pre-hashing @@ -414,6 +415,12 @@ const ( // EdDSA algorithms. Ed25519 SignatureScheme = 0x0807 + // ML-DSA signature algorithms (FIPS 204, RFC 9881). These codepoints are + // defined for TLS 1.3 only. + MLDSA44 SignatureScheme = 0x0904 + MLDSA65 SignatureScheme = 0x0905 + MLDSA87 SignatureScheme = 0x0906 + // Legacy signature and hash algorithms for TLS 1.2. PKCS1WithSHA1 SignatureScheme = 0x0201 ECDSAWithSHA1 SignatureScheme = 0x0203 diff --git a/common_string.go b/common_string.go index e15dd48838b..2e5aefb470e 100644 --- a/common_string.go +++ b/common_string.go @@ -18,6 +18,9 @@ func _() { _ = x[ECDSAWithP384AndSHA384-1283] _ = x[ECDSAWithP521AndSHA512-1539] _ = x[Ed25519-2055] + _ = x[MLDSA44-2308] + _ = x[MLDSA65-2309] + _ = x[MLDSA87-2310] _ = x[PKCS1WithSHA1-513] _ = x[ECDSAWithSHA1-515] } @@ -32,10 +35,12 @@ const ( _SignatureScheme_name_6 = "PKCS1WithSHA512" _SignatureScheme_name_7 = "ECDSAWithP521AndSHA512" _SignatureScheme_name_8 = "PSSWithSHA256PSSWithSHA384PSSWithSHA512Ed25519" + _SignatureScheme_name_9 = "MLDSA44MLDSA65MLDSA87" ) var ( _SignatureScheme_index_8 = [...]uint8{0, 13, 26, 39, 46} + _SignatureScheme_index_9 = [...]uint8{0, 7, 14, 21} ) func (i SignatureScheme) String() string { @@ -59,6 +64,9 @@ func (i SignatureScheme) String() string { case 2052 <= i && i <= 2055: i -= 2052 return _SignatureScheme_name_8[_SignatureScheme_index_8[i]:_SignatureScheme_index_8[i+1]] + case 2308 <= i && i <= 2310: + i -= 2308 + return _SignatureScheme_name_9[_SignatureScheme_index_9[i]:_SignatureScheme_index_9[i+1]] default: return "SignatureScheme(" + strconv.FormatInt(int64(i), 10) + ")" } @@ -72,16 +80,37 @@ func _() { _ = x[CurveP521-25] _ = x[X25519-29] _ = x[X25519MLKEM768-4588] + _ = x[CurveSECP256R1-23] + _ = x[CurveSECP384R1-24] + _ = x[CurveSECP521R1-25] + _ = x[CurveX25519-29] + _ = x[FakeCurveFFDHE2048-256] + _ = x[FakeCurveFFDHE3072-257] + _ = x[FakeCurveFFDHE4096-258] + _ = x[FakeCurveFFDHE6144-259] + _ = x[FakeCurveFFDHE8192-260] + _ = x[X25519Kyber768Draft00-25497] + _ = x[FakeCurveX25519Kyber512Draft00-65072] + _ = x[FakeCurveX25519Kyber768Draft00Old-65073] + _ = x[FakeCurveP256Kyber768Draft00-65074] + _ = x[X25519Kyber512Draft00-65072] + _ = x[X25519Kyber768Draft00Old-65073] + _ = x[P256Kyber768Draft00-65074] } const ( _CurveID_name_0 = "CurveP256CurveP384CurveP521" _CurveID_name_1 = "X25519" - _CurveID_name_2 = "X25519MLKEM768" + _CurveID_name_2 = "FakeCurveFFDHE2048FakeCurveFFDHE3072FakeCurveFFDHE4096FakeCurveFFDHE6144FakeCurveFFDHE8192" + _CurveID_name_3 = "X25519MLKEM768" + _CurveID_name_4 = "X25519Kyber768Draft00" + _CurveID_name_5 = "FakeCurveX25519Kyber512Draft00FakeCurveX25519Kyber768Draft00OldFakeCurveP256Kyber768Draft00" ) var ( _CurveID_index_0 = [...]uint8{0, 9, 18, 27} + _CurveID_index_2 = [...]uint8{0, 18, 36, 54, 72, 90} + _CurveID_index_5 = [...]uint8{0, 30, 63, 91} ) func (i CurveID) String() string { @@ -91,8 +120,16 @@ func (i CurveID) String() string { return _CurveID_name_0[_CurveID_index_0[i]:_CurveID_index_0[i+1]] case i == 29: return _CurveID_name_1 + case 256 <= i && i <= 260: + i -= 256 + return _CurveID_name_2[_CurveID_index_2[i]:_CurveID_index_2[i+1]] case i == 4588: - return _CurveID_name_2 + return _CurveID_name_3 + case i == 25497: + return _CurveID_name_4 + case 65072 <= i && i <= 65074: + i -= 65072 + return _CurveID_name_5[_CurveID_index_5[i]:_CurveID_index_5[i+1]] default: return "CurveID(" + strconv.FormatInt(int64(i), 10) + ")" } @@ -113,8 +150,9 @@ const _ClientAuthType_name = "NoClientCertRequestClientCertRequireAnyClientCertV var _ClientAuthType_index = [...]uint8{0, 12, 29, 49, 72, 98} func (i ClientAuthType) String() string { - if i < 0 || i >= ClientAuthType(len(_ClientAuthType_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_ClientAuthType_index)-1 { return "ClientAuthType(" + strconv.FormatInt(int64(i), 10) + ")" } - return _ClientAuthType_name[_ClientAuthType_index[i]:_ClientAuthType_index[i+1]] + return _ClientAuthType_name[_ClientAuthType_index[idx]:_ClientAuthType_index[idx+1]] } diff --git a/go.mod b/go.mod index 6cc2d35620b..4d61daf73b7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/refraction-networking/utls -go 1.24 +go 1.27 retract ( v1.4.1 // #218 diff --git a/handshake_client.go b/handshake_client.go index bba58a5a9e3..fe1e3883669 100644 --- a/handshake_client.go +++ b/handshake_client.go @@ -10,6 +10,7 @@ import ( "crypto" "crypto/ecdsa" "crypto/ed25519" + "crypto/mldsa" "crypto/mlkem" "crypto/rsa" "crypto/subtle" @@ -1220,6 +1221,11 @@ func (c *Conn) verifyServerCertificate(certificates [][]byte) error { switch certs[0].PublicKey.(type) { case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: break + case *mldsa.PublicKey: + if c.vers < VersionTLS13 { + c.sendAlert(alertIllegalParameter) + return errors.New("tls: server's certificate uses ML-DSA, which requires TLS 1.3") + } default: c.sendAlert(alertUnsupportedCertificate) return fmt.Errorf("tls: server's certificate contains an unsupported type of public key: %T", certs[0].PublicKey) diff --git a/handshake_client_tls13.go b/handshake_client_tls13.go index 01c2756c4c0..e1a22592d20 100644 --- a/handshake_client_tls13.go +++ b/handshake_client_tls13.go @@ -850,7 +850,7 @@ func (hs *clientHandshakeStateTLS13) readServerCertificate() error { } // See RFC 8446, Section 4.4.3. - if !isSupportedSignatureAlgorithm(certVerify.signatureAlgorithm, supportedSignatureAlgorithms()) { + if !isSupportedSignatureAlgorithm(certVerify.signatureAlgorithm, clientSupportedSignatureAlgorithms(c.vers)) { c.sendAlert(alertIllegalParameter) return errors.New("tls: certificate used with invalid signature algorithm") } diff --git a/u_common.go b/u_common.go index a9f90f75e39..23c7d50f55e 100644 --- a/u_common.go +++ b/u_common.go @@ -613,7 +613,7 @@ var ( HelloFirefox_120 = ClientHelloID{helloFirefox, "120", nil, nil} HelloFirefox_148 = ClientHelloID{helloFirefox, "148", nil, nil} - HelloChrome_Auto = HelloChrome_133 + HelloChrome_Auto = HelloChrome_150 HelloChrome_58 = ClientHelloID{helloChrome, "58", nil, nil} HelloChrome_62 = ClientHelloID{helloChrome, "62", nil, nil} HelloChrome_70 = ClientHelloID{helloChrome, "70", nil, nil} @@ -645,6 +645,9 @@ var ( HelloChrome_131 = ClientHelloID{helloChrome, "131", nil, nil} // Chrome w/ New ALPS codepoint HelloChrome_133 = ClientHelloID{helloChrome, "133", nil, nil} + // Chrome w/ ML-DSA signature algorithms + HelloChrome_150 = ClientHelloID{helloChrome, "150", nil, nil} + HelloChrome_150_PSK = ClientHelloID{helloChrome, "150_PSK", nil, nil} HelloIOS_Auto = HelloIOS_14 HelloIOS_11_1 = ClientHelloID{helloIOS, "111", nil, nil} // legacy "111" means 11.1 @@ -660,6 +663,9 @@ var ( HelloSafari_Auto = HelloSafari_26_3 HelloSafari_16_0 = ClientHelloID{helloSafari, "16.0", nil, nil} + HelloSafari_18_5 = ClientHelloID{helloSafari, "18.5", nil, nil} + // Safari 26.0 sends the same ClientHello as Safari 26.3. + HelloSafari_26_0 = ClientHelloID{helloSafari, "26.0", nil, nil} HelloSafari_26_3 = ClientHelloID{helloSafari, "26.3", nil, nil} Hello360_Auto = Hello360_7_5 // Hello360_11_0 seems to be incompatible with this library diff --git a/u_connectivity_test.go b/u_connectivity_test.go new file mode 100644 index 00000000000..3d46fb733bc --- /dev/null +++ b/u_connectivity_test.go @@ -0,0 +1,285 @@ +package tls + +import ( + "context" + "crypto/mldsa" + ctls "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" + + "golang.org/x/net/http2" +) + +// The connectivity test uses a well-known endpoint that answers with status 204 and an +// empty body. A short answer keeps the test independent of page content. +const ( + connectivityHost = "www.google.com" + connectivityAddr = connectivityHost + ":443" + connectivityURL = "https://" + connectivityHost + "/generate_204" + + // Chrome sends this User-Agent with the profile that the test uses. + connectivityUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + + connectivityTimeout = 30 * time.Second +) + +// connectivityResult holds what one request of the connectivity test showed. +type connectivityResult struct { + state ConnectionState + statusCode int + bodyLen int +} + +// TestChrome150PSKConnectsToGoogle makes two real connections to www.google.com with +// the HelloChrome_150_PSK profile. The unit tests show that a server in the same +// process accepts the profile. This test shows that a production server, which applies +// its own rules to the ClientHello, also accepts it. +// +// The first connection makes a full handshake and receives a session ticket. The second +// connection sends the pre_shared_key extension of the profile, and the server must +// resume the session. Both connections must receive status 204. +// +// The test needs internet access. Use "go test -short" to skip it. +func TestChrome150PSKConnectsToGoogle(t *testing.T) { + if testing.Short() { + t.Skipf("skip: the test connects to %s", connectivityAddr) + } + + cache := NewLRUClientSessionCache(0) + + first := getGenerate204(t, cache) + if first.state.DidResume { + t.Error("the first connection resumed a session, although the cache was empty") + } + if first.statusCode != http.StatusNoContent { + t.Errorf("the first connection got status %d, but the test expects %d", + first.statusCode, http.StatusNoContent) + } + + second := getGenerate204(t, cache) + if !second.state.DidResume { + t.Error("the second connection did not resume the session, although the cache held a ticket") + } + if second.statusCode != http.StatusNoContent { + t.Errorf("the second connection got status %d, but the test expects %d", + second.statusCode, http.StatusNoContent) + } +} + +// getGenerate204 makes one request to connectivityURL through a new connection. The +// connection uses the HelloChrome_150_PSK profile and the given session cache. The +// cache is the only state that the calls share. +func getGenerate204(t *testing.T, cache ClientSessionCache) connectivityResult { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), connectivityTimeout) + defer cancel() + + var result connectivityResult + + // The profile offers h2 and http/1.1. Google selects h2, thus the request goes + // through an HTTP/2 transport that dials with utls. + transport := &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string, _ *ctls.Config) (net.Conn, error) { + tcpConn, err := (&net.Dialer{}).DialContext(ctx, network, connectivityAddr) + if err != nil { + return nil, err + } + + uconn := UClient(tcpConn, &Config{ + ServerName: connectivityHost, + NextProtos: []string{"h2", "http/1.1"}, + ClientSessionCache: cache, + OmitEmptyPsk: true, + }, HelloChrome_150_PSK) + + if err := uconn.HandshakeContext(ctx); err != nil { + tcpConn.Close() + return nil, err + } + + result.state = uconn.ConnectionState() + return uconn, nil + }, + } + defer transport.CloseIdleConnections() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, connectivityURL, nil) + if err != nil { + t.Fatalf("cannot make the request: %v", err) + } + req.Header.Set("User-Agent", connectivityUserAgent) + + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("cannot connect to %s: %v (the test needs internet access)", connectivityAddr, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("cannot read the body: %v", err) + } + + result.statusCode = resp.StatusCode + result.bodyLen = len(body) + + t.Logf("%s: version 0x%04x, cipher 0x%04x, alpn %q, resumed %v, status %d, body %d bytes", + connectivityAddr, result.state.Version, result.state.CipherSuite, + result.state.NegotiatedProtocol, result.state.DidResume, + result.statusCode, result.bodyLen) + + return result +} + +// The Open Quantum Safe project runs an NGINX server that holds a separate port for +// each signature and key exchange combination. These 3 ports use ML-DSA server +// authentication. The port numbers come from the table at +// https://test.openquantumsafe.org/, and the server selects X25519MLKEM768 on each one. +const oqsHost = "test.openquantumsafe.org" + +// oqsRootCA is the root of the test server, from https://test.openquantumsafe.org/CA.crt. +// The root uses RSA-4096. The intermediate certificate and the leaf certificate use +// ML-DSA. Thus the chain verification of this test exercises ML-DSA in crypto/x509. +// +// The certificate expires on 30 November 2026. Get the file again after that date. +const oqsRootCA = `-----BEGIN CERTIFICATE----- +MIIFTjCCAzagAwIBAgIUeg1oLTvTiGcNxrOde3kvBXtIAmkwDQYJKoZIhvcNAQEL +BQAwFTETMBEGA1UEAwwKb3FzdGVzdF9DQTAeFw0yNTA3MTgwOTI2NDFaFw0yNjEx +MzAwOTI2NDFaMBUxEzARBgNVBAMMCm9xc3Rlc3RfQ0EwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDiuz/TmHvGcRRdtLmDV1UWwt6Z9/IBUG54G+WVfq8d +uVYsOoCzh6N85GbQOIbM6raEoepAaqpaKEYoi7UGkVzpWnbOTscWq71lJ+T9t+TF +KXXws2xVjTJCJYrKit1taCgO777wwdfE4JzHGGTiWl/2aXJsMNqQ46kpWMu57TgS +XIpUJo95aEGO3MG56IiO5kyvqAHtfCXJiH3CmGqpqGCK30uXeKBi9fqU6xUzrJsL +Om9djmgbqeun/TG83c5mJ7jMOnCzjl0eaZDoHRZgedSyjOLpAu2JZT4DZly9b+oR +xQg9VSFKrojOvZsDkHyuxoUsTTxU2GnUec8ygUWsw8YdbCUf0OWsYStkOgnqnXnC +8ZgUxHgqlJccXpRY1+b+IfWzkmWWTpD6043d+8J+5nyfWlaTyPlq7zvEyz7kCgmq +FjF2in0/Qw7Xe8JRpr3VzKgzsb+plnj7MCkxLzo/f2A45E2N3VP99jap7EuuyQ/J +FjMOKtaggep0fubT4OjWyicDz6sh/uWDGGWW1YZoI9uX7Xdiky5wCxoUSrRetv/h +PZtExudRS9OGsC57kb9vwQSkwyKcXGnQiIrMjBUe8GUP4yu8umXj6ei10s1TysUB +iSRQW2kNN3StGE6sTDuT5X+Knng6ciOVuW/XrsPP3TklQqBLo+ArGXV2+Q/IW7HJ +twIDAQABo4GVMIGSMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIGnIxlFxqHD +68PdPBIbGne/LZ+GMFAGA1UdIwRJMEeAFIGnIxlFxqHD68PdPBIbGne/LZ+GoRmk +FzAVMRMwEQYDVQQDDApvcXN0ZXN0X0NBghR6DWgtO9OIZw3Gs517eS8Fe0gCaTAO +BgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBACQfDyP6jpizi0foDNx0 +evj2vfxNaOxBttOB1mImtUncfTIHFAXG0BldFJJs8NPeyDRn2/7xZ9KpHWTRyIqM +WZfd0fi2bj2+22BGNtmcIAdEuquhP25Mi5N8eT5eBD35Fp5M2ryECpl44R50mDkW +/8Gt+jtIany0ZFKqVZ/0ZsYgC94bx1rp6ZMm67IoxUrG0v7xDTunDOVX+SYVD02E ++ggx3bgUEdFKT9G4NxcZoDAOWiNVj5P5KTv4qoAB6J3pTUFqM9bbjpLLSKh6F752 +fHN/SZBB2aZ0ittVwtgs7NEKqoNpTgfYXX+eAIWnmLU9P6S++xHt1Jvyb/z4hQCn +EeOIm8IzmIqZ07ov1viPtN92Ra1EehikdJTvMMBVA0GttXB4054v6Ro7bfgu04Xr +iICIquMwx4/qxiMFvujP2KMbI2VPhGQi2dhI1ho+Yv9nY9sgc36nWDlQF2OUwrRy +FY4GOve0Dxcwv23oD7GbKgE7fCQ1z78ccLFagzuwTHNPoFL8SrczPJF5/0K8N4GH +5r8GsajDh5AH+plRkq5UObOBh8RLqinUucraF9FCZV6xZ00gqRY43ZqR5VxmH2la +7pxJQCce++0kxgeuFDA9jr5ng8pnYqVSy+vfDy0VseqVq83UwLxtEoGfO7S4Jh/u +gP3YMSSdsj+fvqmrI6j3C6md +-----END CERTIFICATE-----` + +// oqsMLDSAPorts maps each ML-DSA parameter set to its port on the test server. +var oqsMLDSAPorts = []struct { + name string + port int + signature x509.SignatureAlgorithm + parameters mldsa.Parameters +}{ + {"ML-DSA-44", 6184, x509.MLDSA44, mldsa.MLDSA44()}, + {"ML-DSA-65", 6197, x509.MLDSA65, mldsa.MLDSA65()}, + {"ML-DSA-87", 6212, x509.MLDSA87, mldsa.MLDSA87()}, +} + +// TestChrome150PSKConnectsToMLDSAServer makes a real connection to each ML-DSA port of +// the Open Quantum Safe test server with the HelloChrome_150_PSK profile. +// +// The unit tests show that the client verifies ML-DSA against a server in the same +// process. This test shows that the client verifies ML-DSA against an NGINX server that +// uses OpenSSL 3.5 or later. +// +// Each connection proves 3 things. The client offers the ML-DSA codepoints, thus the +// server selects one. The client verifies the CertificateVerify signature of the +// server, which uses the ML-DSA key of the leaf certificate. And crypto/x509 verifies a +// chain in which the intermediate certificate signs the leaf with ML-DSA. +// +// The test needs internet access. Use "go test -short" to skip it. +func TestChrome150PSKConnectsToMLDSAServer(t *testing.T) { + if testing.Short() { + t.Skipf("skip: the test connects to %s", oqsHost) + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(oqsRootCA)) { + t.Fatal("cannot read the root certificate of the test server") + } + + for _, target := range oqsMLDSAPorts { + t.Run(target.name, func(t *testing.T) { + address := net.JoinHostPort(oqsHost, strconv.Itoa(target.port)) + + tcpConn, err := net.DialTimeout("tcp", address, connectivityTimeout) + if err != nil { + t.Fatalf("cannot connect to %s: %v (the test needs internet access)", address, err) + } + defer tcpConn.Close() + + uconn := UClient(tcpConn, &Config{ + ServerName: oqsHost, + RootCAs: roots, + OmitEmptyPsk: true, + }, HelloChrome_150_PSK) + + ctx, cancel := context.WithTimeout(context.Background(), connectivityTimeout) + defer cancel() + + if err := uconn.HandshakeContext(ctx); err != nil { + t.Fatalf("the handshake with %s failed: %v", address, err) + } + defer uconn.Close() + + state := uconn.ConnectionState() + if state.Version != VersionTLS13 { + t.Errorf("the connection uses the version 0x%04x, but ML-DSA needs TLS 1.3", state.Version) + } + if state.testingOnlyCurveID != X25519MLKEM768 { + t.Errorf("the connection uses the group %v, but the test expects X25519MLKEM768", + state.testingOnlyCurveID) + } + + if len(state.PeerCertificates) == 0 { + t.Fatal("the connection holds no peer certificate") + } + leaf := state.PeerCertificates[0] + + // The leaf certificate holds an ML-DSA key. RFC 8446 makes the server sign + // the CertificateVerify message with the key of the leaf certificate. Thus + // this check shows that the client verified an ML-DSA signature. + if leaf.PublicKeyAlgorithm != x509.MLDSA { + t.Errorf("the leaf certificate holds a %v key, but the test expects an ML-DSA key", + leaf.PublicKeyAlgorithm) + } + publicKey, ok := leaf.PublicKey.(*mldsa.PublicKey) + if !ok { + t.Fatalf("the leaf certificate holds the key type %T, but the test expects *mldsa.PublicKey", + leaf.PublicKey) + } + if got := publicKey.Parameters().String(); got != target.parameters.String() { + t.Errorf("the leaf key uses the parameter set %s, but the test expects %s", + got, target.parameters.String()) + } + + // The intermediate certificate signs the leaf certificate with ML-DSA. + if leaf.SignatureAlgorithm != target.signature { + t.Errorf("the intermediate certificate signs the leaf with %v, but the test expects %v", + leaf.SignatureAlgorithm, target.signature) + } + + t.Logf("%s: version 0x%04x, cipher 0x%04x, group %v, leaf key %v, leaf signature %v", + address, state.Version, state.CipherSuite, state.testingOnlyCurveID, + publicKey.Parameters(), leaf.SignatureAlgorithm) + }) + } +} diff --git a/u_cryptocustomrand_test.go b/u_cryptocustomrand_test.go new file mode 100644 index 00000000000..0ca5aecb097 --- /dev/null +++ b/u_cryptocustomrand_test.go @@ -0,0 +1,13 @@ +// The reference tests replay a recorded transcript from testdata. They compare the +// bytes that the client writes against the recorded bytes. Thus the client must make +// the same ephemeral keys on each run. +// +// Go 1.27 gives the io.Reader of the caller to rand.CustomReader in +// ecdh.GenerateKey. That function returns the system source, and not the reader of the +// caller. Thus the deterministic reader of the tests has no effect, and each run makes +// a different key. The setting cryptocustomrand=1 makes rand.CustomReader give the +// reader of the caller through, which makes the tests deterministic again. +// +//go:debug cryptocustomrand=1 + +package tls diff --git a/u_mldsa.go b/u_mldsa.go new file mode 100644 index 00000000000..2fbbf065236 --- /dev/null +++ b/u_mldsa.go @@ -0,0 +1,34 @@ +package tls + +// clientSupportedSignatureAlgorithms returns the signature algorithms that a client +// accepts in the CertificateVerify message of a server. +// +// It adds the ML-DSA schemes to supportedSignatureAlgorithms for TLS 1.3 only, because +// the ML-DSA codepoints are defined for TLS 1.3 only. +// +// The client does not advertise these schemes in a default ClientHello. Only a profile +// that holds the ML-DSA codepoints in its own signature_algorithms extension offers +// them, such as HelloChrome_150. Thus the bytes of a default ClientHello, and the +// fingerprint of HelloGolang, do not change. The reference tests in +// handshake_client_test.go hold this line. +// +// The 3 server call sites of supportedSignatureAlgorithms keep that function. Thus a +// uTLS server does not advertise the ML-DSA codepoints, and rejects an ML-DSA client +// certificate. +// +// Go 1.27 crypto/tls does this differently. It advertises ML-DSA in the default +// ClientHello, and removes the schemes again in isDisabledSignatureAlgorithm. The base +// of this fork is older and has no such function. +func clientSupportedSignatureAlgorithms(vers uint16) []SignatureScheme { + shared := supportedSignatureAlgorithms() + if vers < VersionTLS13 { + return shared + } + + // Make a new slice. An append to shared can write into the array behind + // defaultSupportedSignatureAlgorithms, which the server call sites read. + algorithms := make([]SignatureScheme, 0, len(shared)+3) + algorithms = append(algorithms, MLDSA44, MLDSA65, MLDSA87) + + return append(algorithms, shared...) +} diff --git a/u_mldsa_test.go b/u_mldsa_test.go new file mode 100644 index 00000000000..4bee39adacd --- /dev/null +++ b/u_mldsa_test.go @@ -0,0 +1,278 @@ +package tls + +import ( + "crypto/mldsa" + "crypto/rand" + ctls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "testing" + "time" +) + +// TestMLDSASignatureSchemeValues checks the codepoints of the ML-DSA schemes. The +// values come from RFC 9881 and agree with Go 1.27 crypto/tls. +func TestMLDSASignatureSchemeValues(t *testing.T) { + for _, tc := range []struct { + scheme SignatureScheme + want uint16 + }{ + {MLDSA44, 0x0904}, + {MLDSA65, 0x0905}, + {MLDSA87, 0x0906}, + } { + if uint16(tc.scheme) != tc.want { + t.Errorf("%v holds the value 0x%04x, but the test expects 0x%04x", + tc.scheme, uint16(tc.scheme), tc.want) + } + } +} + +// TestTypeAndHashFromMLDSAScheme checks that the 3 ML-DSA schemes map to the ML-DSA +// signature type, and that they use no pre-hash. +func TestTypeAndHashFromMLDSAScheme(t *testing.T) { + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + sigType, hash, err := typeAndHashFromSignatureScheme(scheme) + if err != nil { + t.Errorf("typeAndHashFromSignatureScheme(%v) gives the error %v", scheme, err) + + continue + } + if sigType != signatureMLDSA { + t.Errorf("%v maps to the signature type %d, but the test expects signatureMLDSA (%d)", + scheme, sigType, signatureMLDSA) + } + if hash != directSigning { + t.Errorf("%v maps to the hash %v, but the test expects directSigning", scheme, hash) + } + } +} + +// TestVerifyMLDSAHandshakeSignature signs a message with an ML-DSA key, then verifies +// the signature through the handshake code. The test uses no certificate, because it +// covers the signature step only. +func TestVerifyMLDSAHandshakeSignature(t *testing.T) { + key, err := mldsa.GenerateKey(mldsa.MLDSA65()) + if err != nil { + t.Fatalf("cannot make an ML-DSA key: %v", err) + } + + signed := []byte("the transcript that the CertificateVerify message signs") + signature, err := key.Sign(rand.Reader, signed, &mldsa.Options{}) + if err != nil { + t.Fatalf("cannot sign: %v", err) + } + + if err := verifyHandshakeSignature(signatureMLDSA, key.PublicKey(), directSigning, signed, signature); err != nil { + t.Errorf("the correct signature does not verify: %v", err) + } + + signed[0] ^= 0xff + if err := verifyHandshakeSignature(signatureMLDSA, key.PublicKey(), directSigning, signed, signature); err == nil { + t.Error("a signature over different data verifies, but the test expects an error") + } +} + +// holdsScheme reports whether a list holds a scheme. +func holdsScheme(list []SignatureScheme, want SignatureScheme) bool { + for _, s := range list { + if s == want { + return true + } + } + + return false +} + +// TestClientSupportedSignatureAlgorithms checks that a client offers the ML-DSA schemes +// for TLS 1.3 only. The ML-DSA codepoints are defined for TLS 1.3 only. +func TestClientSupportedSignatureAlgorithms(t *testing.T) { + tls13 := clientSupportedSignatureAlgorithms(VersionTLS13) + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + if !holdsScheme(tls13, scheme) { + t.Errorf("the TLS 1.3 client list holds no %v", scheme) + } + } + + tls12 := clientSupportedSignatureAlgorithms(VersionTLS12) + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + if holdsScheme(tls12, scheme) { + t.Errorf("the TLS 1.2 client list holds %v, but ML-DSA needs TLS 1.3", scheme) + } + } +} + +// TestServerListHoldsNoMLDSA checks the shared list that the 3 server call sites read. +// A uTLS server must not advertise the ML-DSA codepoints, and must not accept an ML-DSA +// client certificate. This test fails if a later change puts ML-DSA in the shared list. +func TestServerListHoldsNoMLDSA(t *testing.T) { + shared := supportedSignatureAlgorithms() + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + if holdsScheme(shared, scheme) { + t.Errorf("supportedSignatureAlgorithms holds %v, thus a server advertises it", scheme) + } + // This is the guard at handshake_server_tls13.go:1092. + if isSupportedSignatureAlgorithm(scheme, shared) { + t.Errorf("a server accepts a client certificate that uses %v", scheme) + } + } +} + +// TestClientListDoesNotChangeSharedList checks that the client list makes a new slice. +// An append to the array behind defaultSupportedSignatureAlgorithms would put ML-DSA in +// the server list as well. +func TestClientListDoesNotChangeSharedList(t *testing.T) { + before := append([]SignatureScheme(nil), supportedSignatureAlgorithms()...) + + clientSupportedSignatureAlgorithms(VersionTLS13) + + after := supportedSignatureAlgorithms() + if len(before) != len(after) { + t.Fatalf("the shared list holds %d algorithms after the call, but held %d before", + len(after), len(before)) + } + for i := range before { + if before[i] != after[i] { + t.Errorf("the shared list changed at index %d: %v became %v", i, before[i], after[i]) + } + } +} + +// TestServerCertificateRequestHoldsNoMLDSA runs a uTLS server that asks for a client +// certificate. The CertificateRequest message must hold no ML-DSA codepoint, because +// this fork supports ML-DSA for the client role only. The client is the standard +// library, which reports the advertised list in CertificateRequestInfo. +func TestServerCertificateRequestHoldsNoMLDSA(t *testing.T) { + serverConfig := testConfig.Clone() + serverConfig.MinVersion = VersionTLS13 + serverConfig.MaxVersion = VersionTLS13 + serverConfig.ClientAuth = RequireAnyClientCert + + listener := newLocalListener(t) + defer listener.Close() + + go func() { + serverConn, err := listener.Accept() + if err != nil { + return + } + defer serverConn.Close() + + // The handshake fails, because the client sends an empty certificate. The + // test reads the CertificateRequest message before that point. + Server(serverConn, serverConfig).Handshake() + }() + + clientConn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + var offered []uint16 + client := ctls.Client(clientConn, &ctls.Config{ + InsecureSkipVerify: true, + MinVersion: ctls.VersionTLS13, + GetClientCertificate: func(cri *ctls.CertificateRequestInfo) (*ctls.Certificate, error) { + for _, scheme := range cri.SignatureSchemes { + offered = append(offered, uint16(scheme)) + } + + return &ctls.Certificate{}, nil + }, + }) + client.Handshake() + + if len(offered) == 0 { + t.Fatal("the server sent no CertificateRequest message, thus the test proves nothing") + } + for _, scheme := range offered { + if scheme == 0x0904 || scheme == 0x0905 || scheme == 0x0906 { + t.Errorf("the server CertificateRequest message holds the ML-DSA codepoint 0x%04x", scheme) + } + } +} + +// TestClientVerifiesMLDSAServerCertificate runs a full TLS 1.3 handshake against a +// standard library server that holds an ML-DSA certificate. The uTLS client must verify +// the CertificateVerify message of the server, and crypto/x509 must verify the chain. +// +// The server is the standard library, because the uTLS server cannot sign with ML-DSA. +func TestClientVerifiesMLDSAServerCertificate(t *testing.T) { + key, err := mldsa.GenerateKey(mldsa.MLDSA65()) + if err != nil { + t.Fatalf("cannot make an ML-DSA key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "mldsa.example"}, + DNSNames: []string{"mldsa.example"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, key.PublicKey(), key) + if err != nil { + t.Fatalf("cannot make an ML-DSA certificate: %v", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("cannot parse the ML-DSA certificate: %v", err) + } + + roots := x509.NewCertPool() + roots.AddCert(leaf) + + listener := newLocalListener(t) + defer listener.Close() + + serverErr := make(chan error, 1) + go func() { + serverConn, err := listener.Accept() + if err != nil { + serverErr <- err + + return + } + defer serverConn.Close() + + server := ctls.Server(serverConn, &ctls.Config{ + Certificates: []ctls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + MinVersion: ctls.VersionTLS13, + }) + serverErr <- server.Handshake() + }() + + clientConn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + client := UClient(clientConn, &Config{ + ServerName: "mldsa.example", + RootCAs: roots, + OmitEmptyPsk: true, + }, HelloChrome_150_PSK) + if err := client.Handshake(); err != nil { + t.Fatalf("the client handshake failed: %v", err) + } + if err := <-serverErr; err != nil { + t.Fatalf("the server handshake failed: %v", err) + } + + state := client.ConnectionState() + if len(state.PeerCertificates) == 0 { + t.Fatal("the connection holds no peer certificate") + } + got := state.PeerCertificates[0].SignatureAlgorithm + if got != x509.MLDSA65 { + t.Errorf("the certificate uses the signature algorithm %v, but the test expects MLDSA65", got) + } +} diff --git a/u_parrots.go b/u_parrots.go index 8416fa33635..73ee23247b1 100644 --- a/u_parrots.go +++ b/u_parrots.go @@ -2239,7 +2239,116 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { }, }, }, nil - case HelloSafari_26_3: + case HelloSafari_18_5: + return ClientHelloSpec{ + TLSVersMin: VersionTLS10, + TLSVersMax: VersionTLS13, + CipherSuites: []uint16{ + GREASE_PLACEHOLDER, + TLS_AES_128_GCM_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_CHACHA20_POLY1305_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + TLS_RSA_WITH_AES_256_GCM_SHA384, + TLS_RSA_WITH_AES_128_GCM_SHA256, + TLS_RSA_WITH_AES_256_CBC_SHA, + TLS_RSA_WITH_AES_128_CBC_SHA, + FAKE_TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA, + TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, + TLS_RSA_WITH_3DES_EDE_CBC_SHA, + }, + CompressionMethods: []uint8{ + compressionNone, + }, + Extensions: []TLSExtension{ + &UtlsGREASEExtension{}, + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{ + Renegotiation: RenegotiateOnceAsClient, + }, + &SupportedCurvesExtension{ + Curves: []CurveID{ + GREASE_PLACEHOLDER, + X25519, + CurveP256, + CurveP384, + CurveP521, + }, + }, + &SupportedPointsExtension{ + SupportedPoints: []uint8{ + pointFormatUncompressed, + }, + }, + &ALPNExtension{ + AlpnProtocols: []string{ + "h2", + "http/1.1", + }, + }, + &StatusRequestExtension{}, + &SignatureAlgorithmsExtension{ + SupportedSignatureAlgorithms: []SignatureScheme{ + ECDSAWithP256AndSHA256, + PSSWithSHA256, + PKCS1WithSHA256, + ECDSAWithP384AndSHA384, + PSSWithSHA384, + PSSWithSHA384, + PKCS1WithSHA384, + PSSWithSHA512, + PKCS1WithSHA512, + PKCS1WithSHA1, + }, + }, + &SCTExtension{}, + &KeyShareExtension{ + KeyShares: []KeyShare{ + { + Group: GREASE_PLACEHOLDER, + Data: []byte{ + 0, + }, + }, + { + Group: X25519, + }, + }, + }, + &PSKKeyExchangeModesExtension{ + Modes: []uint8{ + PskModeDHE, + }, + }, + &SupportedVersionsExtension{ + Versions: []uint16{ + GREASE_PLACEHOLDER, + VersionTLS13, + VersionTLS12, + VersionTLS11, + VersionTLS10, + }, + }, + &UtlsCompressCertExtension{ + Algorithms: []CertCompressionAlgo{ + CertCompressionZlib, + }, + }, + &UtlsGREASEExtension{}, + &UtlsPaddingExtension{GetPaddingLen: BoringPaddingStyle}, + }, + }, nil + case HelloSafari_26_0, HelloSafari_26_3: return ClientHelloSpec{ TLSVersMin: VersionTLS12, TLSVersMax: VersionTLS13, @@ -2927,6 +3036,159 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { &UtlsPreSharedKeyExtension{}, }), }, nil + case HelloChrome_150: + return ClientHelloSpec{ + CipherSuites: []uint16{ + GREASE_PLACEHOLDER, + TLS_AES_128_GCM_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_CHACHA20_POLY1305_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + TLS_RSA_WITH_AES_128_GCM_SHA256, + TLS_RSA_WITH_AES_256_GCM_SHA384, + TLS_RSA_WITH_AES_128_CBC_SHA, + TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CompressionMethods: []byte{ + compressionNone, + }, + Extensions: ShuffleChromeTLSExtensions([]TLSExtension{ + &UtlsGREASEExtension{}, + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{Renegotiation: RenegotiateOnceAsClient}, + &SupportedCurvesExtension{[]CurveID{ + GREASE_PLACEHOLDER, + X25519MLKEM768, + X25519, + CurveP256, + CurveP384, + }}, + &SupportedPointsExtension{SupportedPoints: []byte{ + pointFormatUncompressed, + }}, + &SessionTicketExtension{}, + &ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}}, + &StatusRequestExtension{}, + &SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []SignatureScheme{ + MLDSA44, + MLDSA65, + MLDSA87, + ECDSAWithP256AndSHA256, + PSSWithSHA256, + PKCS1WithSHA256, + ECDSAWithP384AndSHA384, + PSSWithSHA384, + PKCS1WithSHA384, + PSSWithSHA512, + PKCS1WithSHA512, + }}, + &SCTExtension{}, + &KeyShareExtension{[]KeyShare{ + {Group: CurveID(GREASE_PLACEHOLDER), Data: []byte{0}}, + {Group: X25519MLKEM768}, + {Group: X25519}, + }}, + &PSKKeyExchangeModesExtension{[]uint8{ + PskModeDHE, + }}, + &SupportedVersionsExtension{[]uint16{ + GREASE_PLACEHOLDER, + VersionTLS13, + VersionTLS12, + }}, + &UtlsCompressCertExtension{[]CertCompressionAlgo{ + CertCompressionBrotli, + }}, + &ApplicationSettingsExtensionNew{SupportedProtocols: []string{"h2"}}, + BoringGREASEECH(), + &UtlsGREASEExtension{}, + }), + }, nil + case HelloChrome_150_PSK: + return ClientHelloSpec{ + CipherSuites: []uint16{ + GREASE_PLACEHOLDER, + TLS_AES_128_GCM_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_CHACHA20_POLY1305_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + TLS_RSA_WITH_AES_128_GCM_SHA256, + TLS_RSA_WITH_AES_256_GCM_SHA384, + TLS_RSA_WITH_AES_128_CBC_SHA, + TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CompressionMethods: []byte{ + compressionNone, + }, + Extensions: ShuffleChromeTLSExtensions([]TLSExtension{ + &UtlsGREASEExtension{}, + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{Renegotiation: RenegotiateOnceAsClient}, + &SupportedCurvesExtension{[]CurveID{ + GREASE_PLACEHOLDER, + X25519MLKEM768, + X25519, + CurveP256, + CurveP384, + }}, + &SupportedPointsExtension{SupportedPoints: []byte{ + pointFormatUncompressed, + }}, + &SessionTicketExtension{}, + &ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}}, + &StatusRequestExtension{}, + &SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []SignatureScheme{ + MLDSA44, + MLDSA65, + MLDSA87, + ECDSAWithP256AndSHA256, + PSSWithSHA256, + PKCS1WithSHA256, + ECDSAWithP384AndSHA384, + PSSWithSHA384, + PKCS1WithSHA384, + PSSWithSHA512, + PKCS1WithSHA512, + }}, + &SCTExtension{}, + &KeyShareExtension{[]KeyShare{ + {Group: CurveID(GREASE_PLACEHOLDER), Data: []byte{0}}, + {Group: X25519MLKEM768}, + {Group: X25519}, + }}, + &PSKKeyExchangeModesExtension{[]uint8{ + PskModeDHE, + }}, + &SupportedVersionsExtension{[]uint16{ + GREASE_PLACEHOLDER, + VersionTLS13, + VersionTLS12, + }}, + &UtlsCompressCertExtension{[]CertCompressionAlgo{ + CertCompressionBrotli, + }}, + &ApplicationSettingsExtensionNew{SupportedProtocols: []string{"h2"}}, + BoringGREASEECH(), + &UtlsGREASEExtension{}, + &UtlsPreSharedKeyExtension{}, + }), + }, nil default: if id.Client == helloRandomized || id.Client == helloRandomizedALPN || id.Client == helloRandomizedNoALPN { // Use empty values as they can be filled later by UConn.ApplyPreset or manually. diff --git a/u_parrots_ja4_test.go b/u_parrots_ja4_test.go new file mode 100644 index 00000000000..5548cf1f342 --- /dev/null +++ b/u_parrots_ja4_test.go @@ -0,0 +1,339 @@ +package tls + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "sort" + "strings" + "testing" +) + +// JA4 is a fingerprint of a TLS ClientHello. It has three parts, joined by underscores. +// https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md +// +// a: the transport, the TLS version, whether SNI is present, the number of cipher +// suites, the number of extensions, and the first and last character of the first +// ALPN protocol. +// b: the first 12 hex characters of the SHA-256 of the sorted cipher suites, without +// GREASE. +// c: the first 12 hex characters of the SHA-256 of the sorted extensions, without +// GREASE, SNI and ALPN, then an underscore, then the signature algorithms in the +// order that the ClientHello sends them. +// +// The sort makes JA4 the same for every connection of one profile, although +// ShuffleChromeTLSExtensions gives the extensions a new order for each connection. + +// ja4Captures holds the captured JA4 of each profile. Add a line to test another +// profile. +var ja4Captures = []struct { + id ClientHelloID + ja4 string +}{ + {id: HelloChrome_150, ja4: "t13d1516h2_8daaf6152771_806a8c22fdea"}, + {id: HelloChrome_150_PSK, ja4: "t13d1517h2_8daaf6152771_a87ad97598a9"}, + {id: HelloChrome_133, ja4: "t13d1516h2_8daaf6152771_d8a2da3f94cd"}, + {id: HelloSafari_26_3, ja4: "t13d2013h2_a09f3c656075_7f0f34a4126d"}, + {id: HelloSafari_26_0, ja4: "t13d2013h2_a09f3c656075_7f0f34a4126d"}, + {id: HelloSafari_18_5, ja4: "t13d2014h2_a09f3c656075_e42f34c56612"}, + {id: HelloSafari_16_0, ja4: "t13d2014h2_a09f3c656075_14788d8d241b"}, +} + +// TestProfilesJA4 compares the ClientHello of each profile against its captured +// JA4. A change of a cipher suite, an extension, or a signature algorithm changes the +// JA4, so this test fails if a profile changes by accident. +func TestProfilesJA4(t *testing.T) { + for _, capture := range ja4Captures { + t.Run(capture.id.Str(), func(t *testing.T) { + got := ja4(t, buildJA4ClientHello(t, capture.id)) + if got != capture.ja4 { + t.Errorf("JA4 = %s, want %s", got, capture.ja4) + } + }) + } +} + +// TestProfilesJA4IsStable checks that the JA4 of a profile is the same on every +// connection. Chrome shuffles its extensions, and a fingerprint that changed with the +// shuffle would identify utls rather than the browser. +func TestProfilesJA4IsStable(t *testing.T) { + const connections = 16 + + for _, capture := range ja4Captures { + t.Run(capture.id.Str(), func(t *testing.T) { + for i := 0; i < connections; i++ { + got := ja4(t, buildJA4ClientHello(t, capture.id)) + if got != capture.ja4 { + t.Fatalf("connection %d gave JA4 %s, want %s", i, got, capture.ja4) + } + } + }) + } +} + +// TestChrome150PSKWithoutSession covers the PSK profile without a session. utls then +// conceals the empty pre_shared_key extension, so the ClientHello holds one extension +// less and the fingerprint becomes the one of Chrome 150 without PSK. +func TestChrome150PSKWithoutSession(t *testing.T) { + want := ja4Captures[0].ja4 // Chrome 150 without PSK + + if got := ja4(t, buildJA4ClientHelloWithoutSession(t, HelloChrome_150_PSK)); got != want { + t.Errorf("JA4 = %s, want %s", got, want) + } +} + +// buildJA4ClientHello returns the ClientHello bytes that the given profile produces. A +// profile that holds a pre_shared_key extension gets a session, so that the extension +// goes on the wire with a fixed identity and a fixed binder, which is what a resumed +// connection sends. +func buildJA4ClientHello(t *testing.T, id ClientHelloID) []byte { + t.Helper() + + config := &Config{ServerName: "example.com"} + if profileSendsPSK(t, id) { + config.ClientSessionCache = NewLRUClientSessionCache(1) + } + + uconn := UClient(&net.TCPConn{}, config, id) + if config.ClientSessionCache != nil { + psk := &FakePreSharedKeyExtension{ + Identities: []PskIdentity{{Label: []byte("ja4-test-ticket"), ObfuscatedTicketAge: 0x1234}}, + Binders: [][]byte{make([]byte, 32)}, + } + if err := uconn.SetPskExtension(psk); err != nil { + t.Fatalf("SetPskExtension: %v", err) + } + } + if err := uconn.BuildHandshakeState(); err != nil { + t.Fatalf("BuildHandshakeState: %v", err) + } + + return uconn.HandshakeState.Hello.Raw +} + +// buildJA4ClientHelloWithoutSession returns the ClientHello bytes of a profile that +// carries no session. OmitEmptyPsk lets a PSK profile build a ClientHello although no +// session exists. +func buildJA4ClientHelloWithoutSession(t *testing.T, id ClientHelloID) []byte { + t.Helper() + + uconn := UClient(&net.TCPConn{}, &Config{ServerName: "example.com", OmitEmptyPsk: true}, id) + if err := uconn.BuildHandshakeState(); err != nil { + t.Fatalf("BuildHandshakeState: %v", err) + } + + return uconn.HandshakeState.Hello.Raw +} + +// profileSendsPSK reports whether the spec of a profile holds a pre_shared_key +// extension. +func profileSendsPSK(t *testing.T, id ClientHelloID) bool { + t.Helper() + + spec, err := UTLSIdToSpec(id) + if err != nil { + t.Fatalf("UTLSIdToSpec: %v", err) + } + + for _, extension := range spec.Extensions { + if _, ok := extension.(PreSharedKeyExtension); ok { + return true + } + } + + return false +} + +// ja4 returns the JA4 of a ClientHello. +func ja4(t *testing.T, raw []byte) string { + t.Helper() + + hello := parseJA4ClientHello(t, raw) + + var cipherSuites []string + for _, cipherSuite := range hello.cipherSuites { + if isGREASEUint16(cipherSuite) { + continue + } + cipherSuites = append(cipherSuites, fmt.Sprintf("%04x", cipherSuite)) + } + + var ( + extensions []string + sigAlgs []string + alpn string + version = "12" + sni = "i" + count int + ) + for _, extension := range hello.extensions { + if isGREASEUint16(extension.id) { + continue + } + count++ + + switch extension.id { + case extensionServerName: + sni = "d" + case extensionALPN: + alpn = ja4FirstALPNProtocol(t, extension.body) + case extensionSignatureAlgorithms: + for _, sigAlg := range ja4SignatureAlgorithms(t, extension.body) { + if isGREASEUint16(sigAlg) { + continue + } + sigAlgs = append(sigAlgs, fmt.Sprintf("%04x", sigAlg)) + } + case extensionSupportedVersions: + if ja4SendsTLS13(extension.body) { + version = "13" + } + } + + // JA4 counts SNI and ALPN, but it keeps them out of the sorted list. + if extension.id != extensionServerName && extension.id != extensionALPN { + extensions = append(extensions, fmt.Sprintf("%04x", extension.id)) + } + } + + sort.Strings(cipherSuites) + sort.Strings(extensions) + + alpnCode := "00" + if alpn != "" { + alpnCode = string(alpn[0]) + string(alpn[len(alpn)-1]) + } + + return fmt.Sprintf("t%s%s%02d%02d%s_%s_%s", + version, sni, len(cipherSuites), count, alpnCode, + ja4Hash(strings.Join(cipherSuites, ",")), + ja4Hash(strings.Join(extensions, ",")+"_"+strings.Join(sigAlgs, ","))) +} + +// ja4Hash returns the first 12 hex characters of the SHA-256 of s. +func ja4Hash(s string) string { + sum := sha256.Sum256([]byte(s)) + + return hex.EncodeToString(sum[:])[:12] +} + +// ja4SignatureAlgorithms returns the algorithms of a signature_algorithms extension. +func ja4SignatureAlgorithms(t *testing.T, body []byte) []uint16 { + t.Helper() + + if len(body) < 2 { + t.Fatal("signature_algorithms extension is shorter than its length field") + } + + var sigAlgs []uint16 + for i := 2; i+1 < len(body); i += 2 { + sigAlgs = append(sigAlgs, binary.BigEndian.Uint16(body[i:i+2])) + } + + return sigAlgs +} + +// ja4FirstALPNProtocol returns the first protocol of an ALPN extension. +func ja4FirstALPNProtocol(t *testing.T, body []byte) string { + t.Helper() + + if len(body) < 3 { + t.Fatal("ALPN extension is shorter than its length fields") + } + length := int(body[2]) + if len(body) < 3+length { + t.Fatal("ALPN protocol runs past the end of the extension") + } + + return string(body[3 : 3+length]) +} + +// ja4SendsTLS13 reports whether a supported_versions extension holds TLS 1.3. +func ja4SendsTLS13(body []byte) bool { + for i := 1; i+1 < len(body); i += 2 { + if binary.BigEndian.Uint16(body[i:i+2]) == VersionTLS13 { + return true + } + } + + return false +} + +// ja4ClientHello holds the parts of a ClientHello that JA4 needs. +type ja4ClientHello struct { + cipherSuites []uint16 + extensions []ja4Extension +} + +type ja4Extension struct { + id uint16 + body []byte +} + +// parseJA4ClientHello reads a ClientHello handshake message. It fails the test on a +// short or malformed message, which can only come from this package. +func parseJA4ClientHello(t *testing.T, raw []byte) ja4ClientHello { + t.Helper() + + reader := &ja4ByteReader{t: t, data: raw} + reader.skip(4) // handshake type and length + reader.skip(2 + 32) // legacy version and random + reader.skip(int(reader.uint8())) + cipherSuites := reader.bytes(int(reader.uint16())) + reader.skip(int(reader.uint8())) + extensions := reader.bytes(int(reader.uint16())) + + hello := ja4ClientHello{} + for i := 0; i+1 < len(cipherSuites); i += 2 { + hello.cipherSuites = append(hello.cipherSuites, binary.BigEndian.Uint16(cipherSuites[i:i+2])) + } + + extensionReader := &ja4ByteReader{t: t, data: extensions} + for len(extensionReader.data) > 0 { + id := extensionReader.uint16() + hello.extensions = append(hello.extensions, ja4Extension{ + id: id, + body: extensionReader.bytes(int(extensionReader.uint16())), + }) + } + + return hello +} + +// ja4ByteReader reads the fields of a handshake message in order. Every method fails +// the test if the message is too short. +type ja4ByteReader struct { + t *testing.T + data []byte +} + +func (r *ja4ByteReader) bytes(n int) []byte { + r.t.Helper() + + if len(r.data) < n { + r.t.Fatalf("ClientHello ends after %d bytes, want %d more", len(r.data), n) + } + head := r.data[:n] + r.data = r.data[n:] + + return head +} + +func (r *ja4ByteReader) skip(n int) { + r.t.Helper() + r.bytes(n) +} + +func (r *ja4ByteReader) uint8() uint8 { + r.t.Helper() + + return r.bytes(1)[0] +} + +func (r *ja4ByteReader) uint16() uint16 { + r.t.Helper() + + return binary.BigEndian.Uint16(r.bytes(2)) +} diff --git a/u_psk_resumption_test.go b/u_psk_resumption_test.go new file mode 100644 index 00000000000..d703e759a4f --- /dev/null +++ b/u_psk_resumption_test.go @@ -0,0 +1,158 @@ +package tls + +import ( + "net" + "testing" +) + +// pskHandshakeResult holds what one connection of the resumption test showed. +type pskHandshakeResult struct { + didResume bool + sentPSK bool +} + +// pskProfiles holds the PSK profiles that this test runs. HelloChrome_150_PSK covers +// the current Chrome ClientHello. HelloChrome_114_Padding_PSK_Shuf covers the two +// features that change how the binders are written: a padding extension, whose length +// comes from the length of the other extensions, and the extension shuffle, which must +// keep pre_shared_key last. The other PSK profiles follow the same path as +// HelloChrome_150_PSK. +var pskProfiles = []ClientHelloID{ + HelloChrome_150_PSK, + HelloChrome_114_Padding_PSK_Shuf, +} + +// TestPSKProfilesResumeSession runs two connections of each PSK profile against a TLS +// 1.3 server. The first connection gets a session ticket. The second connection sends +// the pre_shared_key extension of the profile and resumes the session, which works only +// if utls computes the binders over the ClientHello that goes on the wire. +func TestPSKProfilesResumeSession(t *testing.T) { + for _, id := range pskProfiles { + t.Run(id.Str(), func(t *testing.T) { + serverConfig, clientConfig := pskTestConfigs() + + first := runPSKHandshake(t, clientConfig, serverConfig, id) + if first.didResume { + t.Error("the first connection resumed a session, although no ticket existed") + } + if first.sentPSK { + t.Error("the first connection sent a pre_shared_key extension, although no ticket existed") + } + + second := runPSKHandshake(t, clientConfig, serverConfig, id) + if !second.sentPSK { + t.Fatal("the second connection sent no pre_shared_key extension") + } + if !second.didResume { + t.Error("the second connection did not resume the session") + } + }) + } +} + +// TestProfileWithoutPSKDoesNotResume covers a profile that holds no pre_shared_key +// extension. Such a profile cannot resume, although the session cache holds a ticket. +// This is what makes the resumption of the PSK profiles a property of the extension, +// and not of the session cache. +func TestProfileWithoutPSKDoesNotResume(t *testing.T) { + serverConfig, clientConfig := pskTestConfigs() + + runPSKHandshake(t, clientConfig, serverConfig, HelloChrome_150_PSK) + + withoutPSK := runPSKHandshake(t, clientConfig, serverConfig, HelloChrome_150) + if withoutPSK.sentPSK { + t.Error("the profile without a pre_shared_key extension sent one") + } + if withoutPSK.didResume { + t.Error("the profile without a pre_shared_key extension resumed a session") + } +} + +// pskTestConfigs returns a TLS 1.3 server config, and a client config that keeps +// sessions. +func pskTestConfigs() (serverConfig, clientConfig *Config) { + serverConfig = testConfig.Clone() + serverConfig.MinVersion = VersionTLS13 + serverConfig.MaxVersion = VersionTLS13 + + clientConfig = testConfig.Clone() + clientConfig.MinVersion = VersionTLS13 + clientConfig.MaxVersion = VersionTLS13 + clientConfig.ServerName = "example.go.dev" + clientConfig.ClientSessionCache = NewLRUClientSessionCache(4) + // A connection that holds no session yet would fail on the empty pre_shared_key + // extension. OmitEmptyPsk conceals that extension instead. + clientConfig.OmitEmptyPsk = true + + return serverConfig, clientConfig +} + +// runPSKHandshake runs one client connection against a server that this function +// starts. It returns whether the connection resumed a session, and whether the +// ClientHello held a pre_shared_key extension. +func runPSKHandshake(t *testing.T, clientConfig, serverConfig *Config, id ClientHelloID) pskHandshakeResult { + t.Helper() + + listener := newLocalListener(t) + defer listener.Close() + + serverErr := make(chan error, 1) + go func() { + serverConn, err := listener.Accept() + if err != nil { + serverErr <- err + + return + } + defer serverConn.Close() + + server := Server(serverConn, serverConfig) + if err := server.Handshake(); err != nil { + serverErr <- err + + return + } + + // The write lets the client read, and the read of the client processes the + // session tickets that the server sent after the handshake. + _, err = server.Write([]byte{'x'}) + serverErr <- err + }() + + clientConn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + client := UClient(clientConn, clientConfig, id) + if err := client.Handshake(); err != nil { + t.Fatalf("client handshake with %s: %v", id.Str(), err) + } + + buf := make([]byte, 1) + if _, err := client.Read(buf); err != nil { + t.Fatalf("client read with %s: %v", id.Str(), err) + } + if err := <-serverErr; err != nil { + t.Fatalf("server with %s: %v", id.Str(), err) + } + + return pskHandshakeResult{ + didResume: client.ConnectionState().DidResume, + sentPSK: clientHelloHoldsExtension(t, client.HandshakeState.Hello.Raw, extensionPreSharedKey), + } +} + +// clientHelloHoldsExtension reports whether a ClientHello holds one extension. +func clientHelloHoldsExtension(t *testing.T, raw []byte, want uint16) bool { + t.Helper() + + for _, extension := range parseJA4ClientHello(t, raw).extensions { + if extension.id == want { + return true + } + } + + return false +}