From 1915d137e3de45130a411a60705c6311ede9e1eb Mon Sep 17 00:00:00 2001 From: Mingye Chen Date: Sat, 28 Feb 2026 15:43:13 -0700 Subject: [PATCH] feat: add ff 148 spec and impl keyshare reuse For browsers that sends pq hybrid and classical keyshare pairs, it may or may not choose to reuse the classical part between them. Add a helper to be used when crafting the spec to signify that the reuse should happen, and implement the reuse when generating keyshares. Existing specs will still generate keys independently. Add Firefox 148 spec that uses this reuse. Ported from refraction-networking/utls@fc716b2. The only conflict was the imports: mlkem comes from internal/mlkem here, so only crypto/ecdh is added. --- u_common.go | 3 +- u_parrots.go | 182 ++++++++++++++++++++++++++++++++++++++++++++++ u_parrots_test.go | 154 +++++++++++++++++++++++++++++++++++++++ u_public.go | 13 +++- 4 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 u_parrots_test.go diff --git a/u_common.go b/u_common.go index ad4ced721f..fe11c62ae2 100644 --- a/u_common.go +++ b/u_common.go @@ -602,7 +602,7 @@ var ( HelloRandomizedNoALPN = ClientHelloID{helloRandomizedNoALPN, helloAutoVers, nil, nil} // The rest will will parrot given browser. - HelloFirefox_Auto = HelloFirefox_120 + HelloFirefox_Auto = HelloFirefox_148 HelloFirefox_55 = ClientHelloID{helloFirefox, "55", nil, nil} HelloFirefox_56 = ClientHelloID{helloFirefox, "56", nil, nil} HelloFirefox_63 = ClientHelloID{helloFirefox, "63", nil, nil} @@ -611,6 +611,7 @@ var ( HelloFirefox_102 = ClientHelloID{helloFirefox, "102", nil, nil} HelloFirefox_105 = ClientHelloID{helloFirefox, "105", nil, nil} HelloFirefox_120 = ClientHelloID{helloFirefox, "120", nil, nil} + HelloFirefox_148 = ClientHelloID{helloFirefox, "148", nil, nil} HelloChrome_Auto = HelloChrome_133 HelloChrome_58 = ClientHelloID{helloChrome, "58", nil, nil} diff --git a/u_parrots.go b/u_parrots.go index 7fcf0d688d..ca583dab67 100644 --- a/u_parrots.go +++ b/u_parrots.go @@ -5,6 +5,7 @@ package tls import ( + "crypto/ecdh" crand "crypto/rand" "crypto/sha256" "encoding/binary" @@ -23,6 +24,20 @@ import ( var ErrUnknownClientHelloID = errors.New("tls: unknown ClientHelloID") +type hybridClassicalReusePair struct { + hybrid CurveID + classical CurveID +} + +func classicalCurveForHybrid(curveID CurveID) (CurveID, bool) { + switch curveID { + case X25519MLKEM768, X25519Kyber768Draft00: + return X25519, true + default: + return 0, false + } +} + // UTLSIdToSpec converts a ClientHelloID to a corresponding ClientHelloSpec. // // Exported internal function utlsIdToSpec per request. @@ -1456,6 +1471,129 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { }, }, }, nil + case HelloFirefox_148: + return ClientHelloSpec{ + TLSVersMin: VersionTLS12, + TLSVersMax: VersionTLS13, + CipherSuites: []uint16{ + TLS_AES_128_GCM_SHA256, + TLS_CHACHA20_POLY1305_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + 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: []uint8{ + 0x0, // no compression + }, + Extensions: []TLSExtension{ + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{ + Renegotiation: RenegotiateOnceAsClient, + }, + &SupportedCurvesExtension{ + Curves: []CurveID{ + X25519MLKEM768, + X25519, + CurveP256, + CurveP384, + CurveP521, + 0x0100, + 0x0101, + }, + }, + &SupportedPointsExtension{ + SupportedPoints: []uint8{ + 0x0, // uncompressed + }, + }, + &ALPNExtension{ + AlpnProtocols: []string{ + "h2", + "http/1.1", + }, + }, + &StatusRequestExtension{}, + &FakeDelegatedCredentialsExtension{ + SupportedSignatureAlgorithms: []SignatureScheme{ + ECDSAWithP256AndSHA256, + ECDSAWithP384AndSHA384, + ECDSAWithP521AndSHA512, + ECDSAWithSHA1, + }, + }, + &SCTExtension{}, + &KeyShareExtension{ + KeyShares: append( + ReuseHybridAndClassicalKeyShares( + KeyShare{ + Group: X25519MLKEM768, + }, + KeyShare{ + Group: X25519, + }, + ), + KeyShare{ + Group: CurveP256, + }, + ), + }, + &SupportedVersionsExtension{ + Versions: []uint16{ + VersionTLS13, + VersionTLS12, + }, + }, + &SignatureAlgorithmsExtension{ + SupportedSignatureAlgorithms: []SignatureScheme{ + ECDSAWithP256AndSHA256, + ECDSAWithP384AndSHA384, + ECDSAWithP521AndSHA512, + PSSWithSHA256, + PSSWithSHA384, + PSSWithSHA512, + PKCS1WithSHA256, + PKCS1WithSHA384, + PKCS1WithSHA512, + ECDSAWithSHA1, + PKCS1WithSHA1, + }, + }, + &FakeRecordSizeLimitExtension{ + Limit: 0x4001, + }, + &UtlsCompressCertExtension{[]CertCompressionAlgo{ + CertCompressionZlib, + CertCompressionBrotli, + CertCompressionZstd, + }}, + &GREASEEncryptedClientHelloExtension{ + CandidateCipherSuites: []HPKESymmetricCipherSuite{ + { + KdfId: dicttls.HKDF_SHA256, + AeadId: dicttls.AEAD_AES_128_GCM, + }, + { + KdfId: dicttls.HKDF_SHA256, + AeadId: dicttls.AEAD_CHACHA20_POLY1305, + }, + }, + CandidatePayloadLens: []uint16{223}, // +16: 239 + }, + }, + }, nil case HelloIOS_11_1: return ClientHelloSpec{ TLSVersMax: VersionTLS12, @@ -2874,6 +3012,7 @@ func (uconn *UConn) ApplyPreset(p *ClientHelloSpec) error { } case *KeyShareExtension: preferredCurveIsSet := false + reusedClassicalKeys := make(map[hybridClassicalReusePair]*ecdh.PrivateKey) for i := range ext.KeyShares { curveID := ext.KeyShares[i].Group if isGREASEUint16(uint16(curveID)) { // just in case the user set a GREASE value instead of unGREASEd @@ -2884,7 +3023,16 @@ func (uconn *UConn) ApplyPreset(p *ClientHelloSpec) error { continue } + reuseWith := ext.KeyShares[i].hybridClassicalReuseWith if curveID == X25519MLKEM768 || curveID == X25519Kyber768Draft00 { + if reuseWith != 0 { + expectedClassical, ok := classicalCurveForHybrid(curveID) + if !ok || reuseWith != expectedClassical { + return fmt.Errorf("hybrid keyshare reuse mismatch: hybrid %v must pair with classical %v, got %v", + curveID, expectedClassical, reuseWith) + } + } + ecdheKey, err := generateECDHEKey(uconn.config.rand(), X25519) if err != nil { return err @@ -2905,7 +3053,41 @@ func (uconn *UConn) ApplyPreset(p *ClientHelloSpec) error { } uconn.HandshakeState.State13.KeyShareKeys.Mlkem = mlkemKey uconn.HandshakeState.State13.KeyShareKeys.MlkemEcdhe = ecdheKey + if reuseWith != 0 { + reusedClassicalKeys[hybridClassicalReusePair{ + hybrid: curveID, + classical: reuseWith, + }] = ecdheKey + } } else { + if reuseWith != 0 { + expectedClassical, ok := classicalCurveForHybrid(reuseWith) + if !ok { + return fmt.Errorf("classical keyshare reuse mismatch: hybrid group %v is not supported for reuse", reuseWith) + } + if curveID != expectedClassical { + return fmt.Errorf("classical keyshare reuse mismatch: hybrid %v must pair with classical %v, got %v", + reuseWith, expectedClassical, curveID) + } + + reusedKey, ok := reusedClassicalKeys[hybridClassicalReusePair{ + hybrid: reuseWith, + classical: curveID, + }] + if !ok { + return fmt.Errorf("classical keyshare %v is configured to reuse hybrid %v, but no matching hybrid keyshare was generated first", + curveID, reuseWith) + } + + ext.KeyShares[i].Data = reusedKey.PublicKey().Bytes() + if !preferredCurveIsSet { + // only do this once for the first non-grease curve + uconn.HandshakeState.State13.KeyShareKeys.Ecdhe = reusedKey + preferredCurveIsSet = true + } + continue + } + ecdheKey, err := generateECDHEKey(uconn.config.rand(), curveID) if err != nil { return fmt.Errorf("unsupported Curve in KeyShareExtension: %v."+ diff --git a/u_parrots_test.go b/u_parrots_test.go new file mode 100644 index 0000000000..1a28d56db0 --- /dev/null +++ b/u_parrots_test.go @@ -0,0 +1,154 @@ +package tls + +import ( + "bytes" + "net" + "testing" +) + +type incrementingSource struct { + next byte +} + +func (s *incrementingSource) Read(b []byte) (int, error) { + for i := range b { + b[i] = s.next + s.next++ + } + return len(b), nil +} + +func findKeyShareExtension(t *testing.T, exts []TLSExtension) *KeyShareExtension { + t.Helper() + + for _, ext := range exts { + if keyShareExt, ok := ext.(*KeyShareExtension); ok { + return keyShareExt + } + } + + t.Fatal("key_share extension not found") + return nil +} + +func findKeyShareData(t *testing.T, keyShareExt *KeyShareExtension, group CurveID) []byte { + t.Helper() + + for _, keyShare := range keyShareExt.KeyShares { + if keyShare.Group == group { + return keyShare.Data + } + } + + t.Fatalf("key_share for group %v not found", group) + return nil +} + +func newTestUConnWithIncrementingRand() *UConn { + return UClient(&net.TCPConn{}, &Config{ + ServerName: "example.com", + Rand: &incrementingSource{}, + }, HelloCustom) +} + +func fingerprintsWithHybridClassicalKeyShareReuse() []ClientHelloID { + return []ClientHelloID{ + HelloFirefox_148, + } +} + +func TestParrotFingerprintsReuseHybridClassicalKeyShare(t *testing.T) { + for _, helloID := range fingerprintsWithHybridClassicalKeyShareReuse() { + t.Run(helloID.Str(), func(t *testing.T) { + spec, err := UTLSIdToSpec(helloID) + if err != nil { + t.Fatalf("unexpected error creating %s spec: %v", helloID.Str(), err) + } + + uconn := newTestUConnWithIncrementingRand() + if err := uconn.ApplyPreset(&spec); err != nil { + t.Fatalf("unexpected error applying %s spec: %v", helloID.Str(), err) + } + + keyShareExt := findKeyShareExtension(t, uconn.Extensions) + hybridData := findKeyShareData(t, keyShareExt, X25519MLKEM768) + classicalData := findKeyShareData(t, keyShareExt, X25519) + + if len(hybridData) < x25519PublicKeySize { + t.Fatalf("hybrid keyshare is too short: got %d bytes", len(hybridData)) + } + hybridClassicalPart := hybridData[len(hybridData)-x25519PublicKeySize:] + if !bytes.Equal(hybridClassicalPart, classicalData) { + t.Fatalf("expected %s to reuse classical keyshare: hybrid classical part != X25519 keyshare", helloID.Str()) + } + + keys := uconn.HandshakeState.State13.KeyShareKeys + if keys == nil || keys.MlkemEcdhe == nil || keys.Ecdhe == nil { + t.Fatal("expected both hybrid and classical ECDHE private keys to be set") + } + if keys.MlkemEcdhe != keys.Ecdhe { + t.Fatalf("expected %s hybrid/classical keyshares to reuse the same ECDHE private key", helloID.Str()) + } + }) + } +} + +func TestHybridClassicalKeySharesAreIndependentByDefault(t *testing.T) { + spec := ClientHelloSpec{ + TLSVersMin: VersionTLS12, + TLSVersMax: VersionTLS13, + CipherSuites: []uint16{ + TLS_AES_128_GCM_SHA256, + }, + CompressionMethods: []uint8{compressionNone}, + Extensions: []TLSExtension{ + &SupportedCurvesExtension{ + Curves: []CurveID{ + X25519MLKEM768, + X25519, + }, + }, + &KeyShareExtension{ + KeyShares: []KeyShare{ + { + Group: X25519MLKEM768, + }, + { + Group: X25519, + }, + }, + }, + &SupportedVersionsExtension{ + Versions: []uint16{ + VersionTLS13, + VersionTLS12, + }, + }, + }, + } + + uconn := newTestUConnWithIncrementingRand() + if err := uconn.ApplyPreset(&spec); err != nil { + t.Fatalf("unexpected error applying independent keyshare spec: %v", err) + } + + keyShareExt := findKeyShareExtension(t, uconn.Extensions) + hybridData := findKeyShareData(t, keyShareExt, X25519MLKEM768) + classicalData := findKeyShareData(t, keyShareExt, X25519) + + if len(hybridData) < x25519PublicKeySize { + t.Fatalf("hybrid keyshare is too short: got %d bytes", len(hybridData)) + } + hybridClassicalPart := hybridData[len(hybridData)-x25519PublicKeySize:] + if bytes.Equal(hybridClassicalPart, classicalData) { + t.Fatalf("expected independent keyshares by default: hybrid classical part == X25519 keyshare") + } + + keys := uconn.HandshakeState.State13.KeyShareKeys + if keys == nil || keys.MlkemEcdhe == nil || keys.Ecdhe == nil { + t.Fatal("expected both hybrid and classical ECDHE private keys to be set") + } + if keys.MlkemEcdhe == keys.Ecdhe { + t.Fatal("expected independent keyshares by default: hybrid/classical ECDHE private keys should differ") + } +} diff --git a/u_public.go b/u_public.go index 8fb573b35d..925f5041d1 100644 --- a/u_public.go +++ b/u_public.go @@ -628,8 +628,17 @@ func (fh *finishedHash) getPublicObj() FinishedHash { // TLS 1.3 Key Share. See RFC 8446, Section 4.2.8. type KeyShare struct { - Group CurveID `json:"group"` - Data []byte `json:"key_exchange,omitempty"` // optional + Group CurveID `json:"group"` + Data []byte `json:"key_exchange,omitempty"` // optional + hybridClassicalReuseWith CurveID +} + +// ReuseHybridAndClassicalKeyShares marks a hybrid/classical keyshare pair so +// ApplyPreset reuses the same classical key material for both entries. +func ReuseHybridAndClassicalKeyShares(hybrid, classical KeyShare) []KeyShare { + hybrid.hybridClassicalReuseWith = classical.Group + classical.hybridClassicalReuseWith = hybrid.Group + return []KeyShare{hybrid, classical} } func (ks KeyShare) ToPrivate() keyShare {