Skip to content

Commit 056f763

Browse files
committed
scertecd: force renewal on issuer change
Update parseCertMeta to return errNeedNewCert whenever it detects a change in the issuer regardless of how long is left on the certificate lifetime. Add TestParseCertMetaIssuerChange to exercise this behavior. Add .github/workflows/test.yml workflow to run tests for PRs Updates tailscale/corp#28569 Signed-off-by: Patrick O'Doherty <[email protected]>
1 parent c3d6409 commit 056f763

File tree

3 files changed

+100
-12
lines changed

3 files changed

+100
-12
lines changed

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Go Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Check out code
15+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
16+
- uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
17+
with:
18+
go-version: 1.25
19+
- run: go test -v ./...

scertecd/scertecd.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,15 +1013,6 @@ func (s *Server) parseCertMeta(p []byte, privateCA bool) (*certMeta, error) {
10131013
if len(blocks) == 0 {
10141014
return nil, errors.New("no PEM blocks found")
10151015
}
1016-
if privateCA {
1017-
if len(blocks) < 2 {
1018-
return nil, errors.New("not enough PEM blocks found for private CA cert")
1019-
}
1020-
} else {
1021-
if len(blocks) < 3 {
1022-
return nil, errors.New("not enough PEM blocks found for LE cert")
1023-
}
1024-
}
10251016
if !strings.HasSuffix(blocks[0].Type, " PRIVATE KEY") {
10261017
return nil, errors.New("first PEM block is not a private key")
10271018
}
@@ -1052,6 +1043,17 @@ func (s *Server) parseCertMeta(p []byte, privateCA bool) (*certMeta, error) {
10521043
m.ValidStart = c.NotBefore
10531044
}
10541045
}
1046+
if m.Leaf == nil {
1047+
return nil, errors.New("no leaf cert found")
1048+
}
1049+
1050+
isLECert := strings.Contains(m.Leaf.Issuer.Organization[0], "Let's Encrypt")
1051+
if privateCA && isLECert {
1052+
return m, errNeedNewCert
1053+
}
1054+
if isLECert && len(certBlocks) < 2 {
1055+
return nil, errors.New("expected at least two certs (leaf + issuer) for Let's Encrypt cert")
1056+
}
10551057

10561058
if s.Now().After(m.RenewalTime()) {
10571059
return m, errNeedNewCert

scertecd/scertecd_test.go

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package scertecd
22

33
import (
4+
"bytes"
45
"crypto/ed25519"
56
crand "crypto/rand"
67
"crypto/x509"
@@ -19,7 +20,7 @@ import (
1920
"github.com/tailscale/setec/setectest"
2021
)
2122

22-
func rootCA(t *testing.T, orgName string) (privKey ed25519.PrivateKey, cert *x509.Certificate) {
23+
func rootCA(t *testing.T, commonName string) (privKey ed25519.PrivateKey, cert *x509.Certificate) {
2324
t.Helper()
2425

2526
// create the test CA
@@ -34,8 +35,8 @@ func rootCA(t *testing.T, orgName string) (privKey ed25519.PrivateKey, cert *x50
3435
IsCA: true,
3536
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
3637
Subject: pkix.Name{
37-
CommonName: "Test Root CA",
38-
Organization: []string{orgName},
38+
CommonName: commonName,
39+
Organization: []string{commonName},
3940
},
4041
BasicConstraintsValid: true,
4142
}
@@ -61,6 +62,72 @@ func randSerial(t *testing.T) *big.Int {
6162
return n
6263
}
6364

65+
func TestParseCertMetaIssuerChange(t *testing.T) {
66+
lePriv, leCert := rootCA(t, "Let's Encrypt")
67+
68+
// create a leaf cert signed by Let's Encrypt
69+
leafPub, leafPriv, err := ed25519.GenerateKey(crand.Reader)
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
74+
pemBytes := new(bytes.Buffer)
75+
pkb, err := x509.MarshalPKCS8PrivateKey(leafPriv)
76+
if err != nil {
77+
t.Fatalf("error marshaling root private key: %v", err)
78+
}
79+
pem.Encode(pemBytes, &pem.Block{Type: "EC PRIVATE KEY", Bytes: pkb})
80+
leaf := x509.Certificate{
81+
SerialNumber: randSerial(t),
82+
NotBefore: time.Now().Add(-7 * 24 * time.Hour),
83+
NotAfter: time.Now().Add(60 * 24 * time.Hour),
84+
IsCA: false,
85+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
86+
}
87+
88+
leafDER, err := x509.CreateCertificate(crand.Reader, &leaf, leCert, leafPub, lePriv)
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
pem.Encode(pemBytes, &pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
93+
94+
// append the leaf twice (as a fake intermediate) to appease the cert chain length checks
95+
pem.Encode(pemBytes, &pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
96+
97+
cu := certUpdateCheck{
98+
s: &Server{
99+
Prefix: "test/scertec/",
100+
Now: time.Now,
101+
},
102+
dt: domainAndType{
103+
domain: "test.tailscale.example",
104+
typ: "ecdsa",
105+
privateCA: false,
106+
},
107+
mu: sync.Mutex{},
108+
lg: log.New(io.Discard, "", 0),
109+
}
110+
111+
// parse the LE cert successfully (still with 60 days left)
112+
cm, err := cu.s.parseCertMeta(pemBytes.Bytes(), false)
113+
if err != nil {
114+
t.Fatalf("error parsing cert meta: %v", err)
115+
}
116+
if cm == nil {
117+
t.Fatal("got nil cert meta")
118+
}
119+
120+
// swap to private CA mode and assert we get errNeedNewCert when we check again
121+
cu.dt.privateCA = true
122+
_, err = cu.s.parseCertMeta(pemBytes.Bytes(), true)
123+
if err == nil {
124+
t.Fatal("expected error parsing LE cert as private CA, got nil")
125+
}
126+
if err != errNeedNewCert {
127+
t.Fatalf("expected errNeedNewCert, got: %v", err)
128+
}
129+
}
130+
64131
func TestPrivateCARenewal(t *testing.T) {
65132
rootPrivKey, rootCert := rootCA(t, "Tailscale Root CA")
66133

0 commit comments

Comments
 (0)