crypto/tls: allow reading unencrypted tls alert after server hello
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
}
Related Issues
- crypto/tls: client auth failure alert codes can be improved #52113 (closed)
- crypto/tls: client tls 1.3 handshake doesn't return bad certificate error #56371 (closed)
- crypto/tls: does not send an unknown_ca alert to server if server's certificate verification fails #76019
- crypto/tls: missing alert values #35911 (closed)
- crypto/tls: after switching to TLSv1.3+ Connect no longer returns errors #42656 (closed)
- crypto/tls: recent TLS 1.3 changes appear to break cases where client cert should be rejected #28779 (closed)
- crypto/tls: do not enforce legacy_record_version while reading TLS 1.3 records #67910
- proposal: crypto/tls: expose TLS alert type for more precise error checks #35234
- crypto/tls: client should not fail if it sets an ECH config and the server rejects it #75240 (closed)
Related Discussions
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
@FiloSottile @rolandshoemaker