go icon indicating copy to clipboard operation
go copied to clipboard

crypto/tls: allow reading unencrypted tls alert after server hello

Open fl0mb opened this issue 2 weeks ago • 1 comments

Go version

go version go1.25.1 linux/amd64

Output of go env in your module/workspace:

AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/user/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/user/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build611339686=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/home/user/tool/go-valid-cert/go.mod'
GOMODCACHE='/home/user/bin/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/user/bin/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/opt/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/user/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/opt/go/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.25.1'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

I have a simple https server using an untrusted tls cert:

package main

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"fmt"
	"io"
	"log"
	"math/big"
	"net"
	"net/http"
	"os"
	"strings"
	"time"

)

func genTLSmemory(cn string) *tls.Certificate {
	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
	if err != nil {
		panic(err)
	}

	template := x509.Certificate{
		SerialNumber: serialNumber,
		Subject: pkix.Name{
			CommonName: cn,
		},
		NotBefore:             time.Now(),
		NotAfter:              time.Now().AddDate(5, 0, 0),
		BasicConstraintsValid: false,
	}

	privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
	if err != nil {
		panic(err)
	}

	publicKey := &privateKey.PublicKey

	derEncodedCert, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
	if err != nil {
		panic(err)
	}

	cert := &tls.Certificate{
		Certificate: [][]byte{derEncodedCert},
		PrivateKey:  privateKey,
	}
	return cert
}


func main() {

	cert := genTLSmemory("xyz")
	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{*cert},
		MinVersion:   tls.VersionTLS12,
	}
	l, err := net.Listen("tcp", ":8443")
	if err != nil {
		log.Fatalf("Error listening: %s", err)
	}

	Server := &http.Server{
		TLSConfig: tlsConfig,
	}

	mux := http.NewServeMux()

	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "test")
		
	})
	Server.ServeTLS(l, "", "")

}

What did you see happen?

When using TLS 1.2 I see the correct error (unknown certificate). TLS 1.3 however attempts to decrypt the alert message which fails as it is send in plain text.

# TLS 1.2
curl https://127.0.0.1:8443 --tls-max 1.2   
# Server log output
# 2025/12/09 13:07:05 http: TLS handshake error from 127.0.0.1:54982: remote error: tls: unknown certificate authority

# TLS 1.3
curl https://127.0.0.1:8443 
# Server log output
# 2025/12/09 13:07:09 http: TLS handshake error from 127.0.0.1:54994: local error: tls: bad record MAC

While the spec mandates encrypted alert messages:

Like other messages, alert messages are encrypted as specified by the current connection state.

To my understanding It does allow plain text alerts if the handshake is not finished

Note that with the transitions as shown above, clients may send alerts that derive from post-ServerHello messages in the clear or with the early data keys. If clients need to send such alerts, they SHOULD first rekey to the handshake keys if possible.

What did you expect to see?

I would like to see plaintext alert messages in TLS 1.3, similar to TLS 1.2. This should allow better debugging of TLS issues. A quick google search for local error: tls: bad record MAC yields many results with users confused about the meaning.

Plaintext messages could be read but because of tls.(*halfConn).setTrafficSecret (/crypto/tls/conn.go:230)...

func (hc *halfConn) setTrafficSecret(suite *cipherSuiteTLS13, level QUICEncryptionLevel, secret []byte) {
	hc.trafficSecret = secret
	hc.level = level
	key, iv := suite.trafficKey(secret)
	hc.cipher = suite.aead(key, iv)  // <-- cipher is set
	for i := range hc.seq {
		hc.seq[i] = 0
	}
}

cipher is not nil in tls.(*halfConn).decrypt:

func (hc *halfConn) decrypt(record []byte) ([]byte, recordType, error) {
....
	if hc.cipher != nil {
		switch c := hc.cipher.(type) {
		case cipher.Stream:
			c.XORKeyStream(payload, payload)
		case aead:
			if len(payload) < explicitNonceLen {
				return nil, 0, alertBadRecordMAC
			}
			nonce := payload[:explicitNonceLen]
			if len(nonce) == 0 {
				nonce = hc.seq[:]
			}
			payload = payload[explicitNonceLen:]

			var additionalData []byte
			if hc.version == VersionTLS13 {
				additionalData = record[:recordHeaderLen]
			} else {
				additionalData = append(hc.scratchBuf[:0], hc.seq[:]...)
				additionalData = append(additionalData, record[:3]...)
				n := len(payload) - c.Overhead()
				additionalData = append(additionalData, byte(n>>8), byte(n))
			}

			var err error
			plaintext, err = c.Open(payload[:0], nonce, payload, additionalData)
			if err != nil {
				return nil, 0, alertBadRecordMAC  // <-- observed bad record MAC error 
			}
....
	} else {
		plaintext = payload
	}

fl0mb avatar Dec 09 '25 15:12 fl0mb

@FiloSottile @rolandshoemaker

dr2chase avatar Dec 12 '25 22:12 dr2chase