Skip to content

crypto/tls: LocalCertificate is populated on resumed TLS <=1.2 server connections #79967

@sin99xx

Description

@sin99xx

Go version

go1.27-devel (master, commit 6e04f9f)

What did you do?

ConnectionState.LocalCertificate was added in CL 788866 (commit efbecbb, #24673) with the documented contract:

LocalCertificate is the certificate chain presented to the peer, if any, during the handshake. This field is only populated for connections which are not resumed (DidResume is false).

Run two sequential handshakes (second one resumes) with the connection pinned to TLS 1.2, and inspect the server-side ConnectionState:

package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"fmt"
	"math/big"
	"time"
)

func main() {
	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	tmpl := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{CommonName: "repro"},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     time.Now().Add(time.Hour),
		DNSNames:     []string{"localhost"},
	}
	der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}

	for _, vers := range []uint16{tls.VersionTLS12, tls.VersionTLS13} {
		serverCfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, MaxVersion: vers}
		clientCfg := &tls.Config{InsecureSkipVerify: true, ClientSessionCache: tls.NewLRUClientSessionCache(8), MinVersion: tls.VersionTLS12, MaxVersion: vers}

		ln, _ := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
		states := make(chan tls.ConnectionState, 2)
		go func() {
			for i := 0; i < 2; i++ {
				c, _ := ln.Accept()
				tc := c.(*tls.Conn)
				tc.Handshake()
				states <- tc.ConnectionState()
				tc.Close()
			}
		}()
		for i := 0; i < 2; i++ {
			c, _ := tls.Dial("tcp", ln.Addr().String(), clientCfg)
			c.Read(make([]byte, 1))
			c.Close()
			ss := <-states
			fmt.Printf("%s handshake#%d: DidResume=%v len(LocalCertificate)=%d\n",
				tls.VersionName(vers), i+1, ss.DidResume, len(ss.LocalCertificate))
		}
		ln.Close()
	}
}

What did you see happen?

TLS 1.2 handshake#1: DidResume=false len(LocalCertificate)=1
TLS 1.2 handshake#2: DidResume=true len(LocalCertificate)=1   <-- contract violation
TLS 1.3 handshake#1: DidResume=false len(LocalCertificate)=1
TLS 1.3 handshake#2: DidResume=true len(LocalCertificate)=0

On a resumed TLS ≤1.2 server connection, LocalCertificate reports a certificate chain that was never presented to the peer (no Certificate message is sent on the abbreviated handshake). The populated value is also visible to VerifyConnection and WrapSession callbacks during the resumed handshake.

Why

In the TLS ≤1.2 server flow, serverHandshakeState.handshake() calls processClientHello() before checkForResumption(), and CL 788866 placed the assignment inside processClientHello:

// handshake_server.go (processClientHello)
if hs.cert != nil {
	hs.c.localCertificate = hs.cert.Certificate
}

Nothing clears it on the doResumeHandshake() path. TLS 1.3 is unaffected because pickCertificate() returns early when hs.usingPSK is set.

Why the CL's own test didn't catch it

testLocalCertificateResumption (and testLocalCertificate) set only MinVersion = version and never MaxVersion, so every "TLS 1.0/1.1/1.2" subtest silently negotiates TLS 1.3 — the ≤1.2 path has no coverage. After pinning MaxVersion, the resumption test fails on master for TLS 1.0/1.1/1.2.

Suggested fix

Move the assignment to doFullHandshake, next to where the Certificate message is actually written, and pin MaxVersion in both tests:

--- a/src/crypto/tls/handshake_server.go
+++ b/src/crypto/tls/handshake_server.go
@@ (processClientHello)
-	if hs.cert != nil {
-		hs.c.localCertificate = hs.cert.Certificate
-	}
-
 	if hs.clientHello.scts {
 		hs.hello.scts = hs.cert.SignedCertificateTimestamps
 	}
@@ (doFullHandshake)
 	certMsg := new(certificateMsg)
 	certMsg.certificates = hs.cert.Certificate
+	// Set localCertificate here, rather than at certificate selection time, so
+	// that it is only populated when a certificate is actually presented to the
+	// peer, and not on resumed connections.
+	c.localCertificate = hs.cert.Certificate
 	if _, err := hs.c.writeHandshakeRecord(certMsg, &hs.finishedHash); err != nil {
 		return err
 	}
--- a/src/crypto/tls/tls_test.go
+++ b/src/crypto/tls/tls_test.go
@@ (testLocalCertificate)
 	clientConfig.MinVersion, serverConfig.MinVersion = version, version
+	clientConfig.MaxVersion, serverConfig.MaxVersion = version, version
@@ (testLocalCertificateResumption)
 	clientConfig.MinVersion, serverConfig.MinVersion = version, version
+	clientConfig.MaxVersion, serverConfig.MaxVersion = version, version

With this patch: the strengthened resumption test fails on unpatched master at TLS 1.0/1.1/1.2 and passes with the fix; the full go test crypto/tls suite passes; the repro above prints len(LocalCertificate)=0 for the resumed TLS 1.2 handshake.

cc CL 788866 author — happy to send this as a CL if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions