net/bakedroots: add LetsEncrypt Generation Y roots (YE, YR)

LetsEncrypt announced its new "Generation Y" root hierarchy on
2025-11-24 and switched its default ACME profile to issue from the new
roots in May 2026. Our baked-in fallback root store only contained the
Generation X roots (ISRG Root X1 and X2), so chains terminating at the
new ISRG Root YE (ECDSA P-384) or ISRG Root YR (RSA 4096) roots failed
to verify when the system roots were also missing them.

Add both new self-signed roots, fetched from
https://letsencrypt.org/certificates/ (valid 2025-09-03 to 2045-09-02).

Also add a live network test, run by default in CI only (or with
--run-live-lets-encrypt-test), that verifies the baked-in roots alone
are sufficient to validate LetsEncrypt's per-root test endpoints for
X1, X2, YE, and YR.

Fixes #20527

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ic69076d8ae5aae08db167095745e92c53357afe3
This commit is contained in:
Brad Fitzpatrick
2026-07-20 07:23:35 -07:00
committed by Brad Fitzpatrick
parent 82cfea90ca
commit f65372c9ba
2 changed files with 113 additions and 7 deletions
+63 -5
View File
@@ -15,13 +15,11 @@ import (
// Get returns the baked-in roots.
//
// As of 2025-01-21, this includes only the LetsEncrypt ISRG Root X1 & X2 roots.
// As of 2026-07-20, this includes only the LetsEncrypt ISRG roots:
// the Generation X roots (X1 & X2) and the Generation Y roots (YE & YR).
func Get() *x509.CertPool {
roots.once.Do(func() {
roots.parsePEM(append(
[]byte(letsEncryptX1),
letsEncryptX2...,
))
roots.parsePEM([]byte(letsEncryptX1 + letsEncryptX2 + letsEncryptYE + letsEncryptYR))
})
return roots.p
}
@@ -145,3 +143,63 @@ tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
`
// letsEncryptYE is the ISRG Root YE, part of LetsEncrypt's
// "Generation Y" hierarchy announced 2025-11-24.
//
// Subject: C = US, O = ISRG, CN = Root YE
// Key type: ECDSA P-384
// Validity: 2025-09-03 to 2045-09-02
const letsEncryptYE = `
-----BEGIN CERTIFICATE-----
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL
MAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxEDAOBgNVBAMTB1Jvb3QgWUUwHhcN
MjUwOTAzMDAwMDAwWhcNNDUwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsG
A1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZRTB2MBAGByqGSM49AgEGBSuBBAAi
A2IABDwS/6vhrcVqcbBo+wgdI3fwn9x7DNJJOY/lTOti0vkwuRN87RhEhTH17E7X
yFjWsPYhIPt/wzOqxTd2b+4ZJNy9ID04YywF9U5zasDVyGSNErVNtz8uSGh5izW8
7j77GaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
BBYEFKPIJlqOoUzQNWP8myPIOq5W809WMAoGCCqGSM49BAMDA2cAMGQCMHhMr8N9
LdL1VQKs9BdV81r76eXRB6mtjuNjzk6/lBsPNToWLTDzGYgtQKO1jl63uAIwGV7m
onyF377c+MM1oqVNs17sgu7F9YKZwgLmVbeOMDbKAXHtKMDLbiGllCcs8f47
-----END CERTIFICATE-----
`
// letsEncryptYR is the ISRG Root YR, part of LetsEncrypt's
// "Generation Y" hierarchy announced 2025-11-24.
//
// Subject: C = US, O = ISRG, CN = Root YR
// Key type: RSA 4096
// Validity: 2025-09-03 to 2045-09-02
const letsEncryptYR = `
-----BEGIN CERTIFICATE-----
MIIFKTCCAxGgAwIBAgIRAOxGNJNgz0sP+KmC2Tqpyj0wDQYJKoZIhvcNAQELBQAw
LjELMAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxEDAOBgNVBAMTB1Jvb3QgWVIw
HhcNMjUwOTAzMDAwMDAwWhcNNDUwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzEN
MAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEB
BQADggIPADCCAgoCggIBANvGJnN78CTJdWL3+eGfsLN5TrNBJs+VH9hRXqRbwxu9
sGNiB0BD1fcOxbSUQCJIM1xE13Db+5Cw1w0s0EBYsvuIP/6joF0w8cuImbgR1OGg
YbSQ4OpzI+DG8SGuTlcE873OCS+kh3srlo6vl43M5OJg4Aeo1sfHp6kTJDoIiFBN
JAY+OKfX/FUvYKuhjT+no49lmqmupSBI5PkBQiqrEGtWU5uxU/cQWHGu8jSjFBzn
ZqvbNPLMXMLFxCb3WTfrJBXXjqvWG+v4bjzxjjeAtOlU7qarRDvNOyAuQYLln904
M+faKx8hnLCpJ15ZqaEgcNlY+9MMWcC5yvL2A2j3l9+2buggZX+dOE91zYmIdawT
vSZuVvlbRrAlLxIB6pwMBjneXCjYQ8+3BCCjssbSNpZU3hTcBDdhfAlEDlYr6pEa
tnMdmDT5BqnKC92bd0EhM1fbLHioLccLCuievT8ZkPhZrq7Mii7gNXAcUEAR8+lz
Yal+9zTg7C5DALyVOeG/CqfRAMn1KSHCR0NSA6P8tn/mGRlnCct5rtVCLnVySVpU
6H1qGg3DgTOuskf8eahTMiYbI5ezPJmO5ertalskQ1utp74+eDy92PI4ftHKTbq9
IWhH4YZKh3WnJEIt+oQvlYZbY8tpEroKrFB6PFGzrJIDRyts4HqvuH52RFj2zv/B
AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBTe51tg0CJtQCh9Pw0B/qS1UrRRlDANBgkqhkiG9w0BAQsFAAOCAgEAWHnf
713Bdkq7t5yN2dNIgQakUb94X9WuyhMEHHkgx4oDpSUlnG0w4g94MoqaEUE31ZjR
LU7L5LD1g9ujFHTQu8AD215AHMVQFbm6j8hQxdXHAzDajFNQnOlDJrLjzIx176oy
AjvUtejZx2NNmdb5fd0WGVGsCdoAJ3N8ozo7ajE8t6vfxStZb4BQ9WYJGHUDrv2N
i5tJF6CNiPnlzs3BUfECRbE4JSk+jvy8+VoGiFE8qsH/j78x2fjgQhAQFV7P7Zxy
dBTZ1wEkNpZNW2qnaK1SKBLa+xf6E06YRIq5uaI+HWH8SY1y5VbRgzq40EKg3yxP
06fz+uYAUIFJoLNfhwRCc3Q6pQVuMX3yAjHAes4gk4moGcLQ5p7HAh39yeylZc1J
41sx/jKwLIkPE6Rr1Nf4pxdsxf9SA4yOEiAkDgq04DVxn8hgYFdUtBCuiuVC2heA
EiqVEa+8QZjuw8Gj0EbHXcRd1nInvGqRS1o9Is7YBdQN57X1AYveGBNNqjICSb7c
awuw1EawTDrs13VUlJVEsbQ0/O/1aaV73mCdOQ8azqL2KTv1Ewu1xbquE2S+kdQU
To9TUwat3wUA6cwXh1EfpS/3fJ0aGah5hdpRyoCLDlsSn8tkrjMfFFX0viC+GxHc
sI1ANRYvqSFC2X1VRZfDg+wD6E21BccmifG4yWc=
-----END CERTIFICATE-----
`
+50 -2
View File
@@ -4,16 +4,23 @@
package bakedroots
import (
"crypto/tls"
"flag"
"io"
"net/http"
"slices"
"testing"
"time"
"tailscale.com/util/cibuild"
)
func TestBakedInRoots(t *testing.T) {
ResetForTest(t, nil)
p := Get()
got := p.Subjects()
if len(got) != 2 {
t.Errorf("subjects = %v; want 2", len(got))
if len(got) != 4 {
t.Errorf("subjects = %v; want 4", len(got))
}
// TODO(bradfitz): is there a way to easily make this test prettier without
@@ -25,8 +32,49 @@ func TestBakedInRoots(t *testing.T) {
want := []string{
"0O1\v0\t\x06\x03U\x04\x06\x13\x02US1)0'\x06\x03U\x04\n\x13 Internet Security Research Group1\x150\x13\x06\x03U\x04\x03\x13\fISRG Root X1",
"0O1\v0\t\x06\x03U\x04\x06\x13\x02US1)0'\x06\x03U\x04\n\x13 Internet Security Research Group1\x150\x13\x06\x03U\x04\x03\x13\fISRG Root X2",
"0.1\v0\t\x06\x03U\x04\x06\x13\x02US1\r0\v\x06\x03U\x04\n\x13\x04ISRG1\x100\x0e\x06\x03U\x04\x03\x13\aRoot YE",
"0.1\v0\t\x06\x03U\x04\x06\x13\x02US1\r0\v\x06\x03U\x04\n\x13\x04ISRG1\x100\x0e\x06\x03U\x04\x03\x13\aRoot YR",
}
if !slices.Equal(name, want) {
t.Errorf("subjects = %q; want %q", name, want)
}
}
var runLiveLetsEncryptTest = flag.Bool("run-live-lets-encrypt-test", cibuild.On(),
"run tests that hit LetsEncrypt's live test-certs endpoints over the network")
// TestLiveLetsEncrypt verifies that the baked-in roots alone (without any
// system roots) are sufficient to validate the certificate chains served by
// LetsEncrypt's per-root test endpoints.
func TestLiveLetsEncrypt(t *testing.T) {
if !*runLiveLetsEncryptTest {
t.Skip("skipping live network test; set --run-live-lets-encrypt-test to run")
}
ResetForTest(t, nil)
for _, host := range []string{
"valid.ye.test-certs.letsencrypt.org",
"valid.yr.test-certs.letsencrypt.org",
"valid.x2.test-certs.letsencrypt.org",
"valid.x1.test-certs.letsencrypt.org",
} {
t.Run(host, func(t *testing.T) {
c := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{
RootCAs: Get(), // baked-in roots only; no system roots
},
},
}
res, err := c.Get("https://" + host + "/")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
io.Copy(io.Discard, res.Body)
t.Logf("status: %v", res.Status)
})
}
}