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.
Go version
go1.27-devel (master, commit 6e04f9f)
What did you do?
ConnectionState.LocalCertificatewas added in CL 788866 (commit efbecbb, #24673) with the documented contract:Run two sequential handshakes (second one resumes) with the connection pinned to TLS 1.2, and inspect the server-side
ConnectionState:What did you see happen?
On a resumed TLS ≤1.2 server connection,
LocalCertificatereports 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 toVerifyConnectionandWrapSessioncallbacks during the resumed handshake.Why
In the TLS ≤1.2 server flow,
serverHandshakeState.handshake()callsprocessClientHello()beforecheckForResumption(), and CL 788866 placed the assignment insideprocessClientHello:Nothing clears it on the
doResumeHandshake()path. TLS 1.3 is unaffected becausepickCertificate()returns early whenhs.usingPSKis set.Why the CL's own test didn't catch it
testLocalCertificateResumption(andtestLocalCertificate) set onlyMinVersion = versionand neverMaxVersion, so every "TLS 1.0/1.1/1.2" subtest silently negotiates TLS 1.3 — the ≤1.2 path has no coverage. After pinningMaxVersion, 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 pinMaxVersionin both tests: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/tlssuite passes; the repro above printslen(LocalCertificate)=0for the resumed TLS 1.2 handshake.cc CL 788866 author — happy to send this as a CL if useful.