Skip to content

Commit a28bdd5

Browse files
committed
cmd/sunlight: add support for automatically fetching roots from CCADB
Fixes #36
1 parent db7cb6a commit a28bdd5

File tree

6 files changed

+309
-24
lines changed

6 files changed

+309
-24
lines changed

cmd/sunlight/roots.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/csv"
7+
"encoding/pem"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"slices"
13+
"strings"
14+
"time"
15+
16+
"filippo.io/sunlight/internal/ctlog"
17+
"github.com/google/certificate-transparency-go/x509"
18+
"github.com/google/certificate-transparency-go/x509util"
19+
)
20+
21+
func loadRoots(ctx context.Context, lc LogConfig, l *ctlog.Log) error {
22+
rootsPEM, err := os.ReadFile(lc.Roots)
23+
if err != nil {
24+
return err
25+
}
26+
if err := l.SetRootsFromPEM(ctx, rootsPEM); err != nil {
27+
return err
28+
}
29+
return nil
30+
}
31+
32+
func loadCCADBRoots(ctx context.Context, lc LogConfig, l *ctlog.Log) (newRoots bool, err error) {
33+
old := l.RootsPEM()
34+
buf := bytes.NewBuffer(old)
35+
pool := x509util.NewPEMCertPool()
36+
pool.AppendCertsFromPEM(old)
37+
addRoot := func(cert *x509.Certificate, source string) {
38+
if pool.Included(cert) {
39+
return
40+
}
41+
newRoots = true
42+
pool.AddCert(cert)
43+
fmt.Fprintf(buf, "\n# %s\n# added on %s from %s\n%s\n",
44+
cert.Subject.String(),
45+
time.Now().Format(time.RFC3339),
46+
source,
47+
pem.EncodeToMemory(&pem.Block{
48+
Type: "CERTIFICATE",
49+
Bytes: cert.Raw,
50+
}),
51+
)
52+
}
53+
54+
mergeDelayCert, err := x509util.CertificateFromPEM([]byte(mergeDelayRoot))
55+
if err != nil {
56+
return false, fmt.Errorf("failed to parse merge delay root: %w", err)
57+
}
58+
addRoot(mergeDelayCert, "Sunlight")
59+
60+
url := "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesIncludedByRSReportCSV"
61+
if lc.CCADBRoots == "testing" {
62+
url = "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesInclusionReportCSV"
63+
}
64+
certs, err := CCADBRoots(ctx, url)
65+
if err != nil {
66+
return false, err
67+
}
68+
for _, cert := range certs {
69+
addRoot(cert, "CCADB")
70+
}
71+
72+
if lc.ExtraRoots != "" {
73+
extraBytes, err := os.ReadFile(lc.ExtraRoots)
74+
if err != nil {
75+
return false, fmt.Errorf("failed to read extra roots file %q: %w", lc.ExtraRoots, err)
76+
}
77+
extra, err := x509util.CertificatesFromPEM(extraBytes)
78+
if err != nil {
79+
return false, fmt.Errorf("failed to parse extra roots file %q: %w", lc.ExtraRoots, err)
80+
}
81+
for _, cert := range extra {
82+
addRoot(cert, "extra roots file")
83+
}
84+
}
85+
86+
if !newRoots {
87+
return false, nil
88+
}
89+
return true, l.SetRootsFromPEM(ctx, buf.Bytes())
90+
}
91+
92+
var CCADBClient = &http.Client{
93+
Timeout: 10 * time.Second,
94+
}
95+
96+
func CCADBRoots(ctx context.Context, url string) ([]*x509.Certificate, error) {
97+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
98+
if err != nil {
99+
return nil, err
100+
}
101+
req.Header.Set("User-Agent", "+https://filippo.io/sunlight")
102+
resp, err := CCADBClient.Do(req)
103+
if err != nil {
104+
return nil, fmt.Errorf("failed to fetch CCADB CSV: %w", err)
105+
}
106+
defer resp.Body.Close()
107+
if resp.StatusCode != http.StatusOK {
108+
return nil, fmt.Errorf("failed to fetch CCADB CSV: %s", resp.Status)
109+
}
110+
111+
csvReader := csv.NewReader(resp.Body)
112+
hdr, err := csvReader.Read()
113+
if err != nil {
114+
return nil, fmt.Errorf("failed to read CCADB CSV header: %w", err)
115+
}
116+
pemIdx := slices.Index(hdr, "X.509 Certificate (PEM)")
117+
if pemIdx < 0 {
118+
return nil, fmt.Errorf("CCADB CSV header does not contain %q", "X.509 Certificate (PEM)")
119+
}
120+
usesIdx := slices.Index(hdr, "Intended Use Case(s) Served")
121+
if usesIdx < 0 {
122+
return nil, fmt.Errorf("CCADB CSV header does not contain %q", "Intended Use Case(s) Served")
123+
}
124+
var certificates []*x509.Certificate
125+
for {
126+
row, err := csvReader.Read()
127+
if err == io.EOF {
128+
break
129+
}
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to read CCADB CSV row: %w", err)
132+
}
133+
if len(row) <= pemIdx || len(row) <= usesIdx {
134+
return nil, fmt.Errorf("CCADB CSV row is too short: %q", row)
135+
}
136+
// There is an "Example CA" row with an empty PEM column.
137+
if row[pemIdx] == "" {
138+
continue
139+
}
140+
if !strings.Contains(row[usesIdx], "Server Authentication (TLS) 1.3.6.1.5.5.7.3.1") {
141+
continue
142+
}
143+
cert, err := x509util.CertificateFromPEM([]byte(row[pemIdx]))
144+
if err != nil {
145+
return nil, fmt.Errorf("failed to parse CCADB certificate: %w\n%q", err, row)
146+
}
147+
certificates = append(certificates, cert)
148+
}
149+
if len(certificates) == 0 {
150+
return nil, fmt.Errorf("no certificates found in CCADB CSV")
151+
}
152+
return certificates, nil
153+
}
154+
155+
const mergeDelayRoot = `
156+
-----BEGIN CERTIFICATE-----
157+
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
188+
eg==
189+
-----END CERTIFICATE-----
190+
`

cmd/sunlight/roots_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestCCADBRoots(t *testing.T) {
6+
t.Run("Prod", func(t *testing.T) {
7+
url := "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesIncludedByRSReportCSV"
8+
testCCADBRoots(t, url)
9+
})
10+
t.Run("Testing", func(t *testing.T) {
11+
url := "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesInclusionReportCSV"
12+
testCCADBRoots(t, url)
13+
})
14+
}
15+
16+
func testCCADBRoots(t *testing.T, url string) {
17+
certs, err := CCADBRoots(t.Context(), url)
18+
if err != nil {
19+
t.Fatalf("failed to load CCADB roots: %v", err)
20+
}
21+
if len(certs) < 50 {
22+
t.Fatalf("expected at least 50 CCADB roots, got %d", len(certs))
23+
}
24+
t.Logf("loaded %d CCADB roots", len(certs))
25+
}

cmd/sunlight/sunlight.go

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,33 @@ type LogConfig struct {
207207

208208
// Roots is the path to the accepted roots as a PEM file. They are reloaded
209209
// on SIGHUP from the same path (i.e. the config file itself is not
210-
// reloaded).
210+
// reloaded). Optional. Only one of CCADBRoots and Roots can be set.
211+
//
212+
// Note that the contents of this file are uploaded to Backend as-is.
211213
Roots string
212214

215+
// CCADBRoots can be "trusted" or "testing". Optional. It defaults to
216+
// "trusted" if Roots is not set.
217+
//
218+
// If "trusted", the log will fetch accepted roots from the CCADB list of
219+
// CAs trusted by at least one of the CCADB root stores. If "testing", the
220+
// log will fetch CAs that have applied to at least one of the CCADB root
221+
// stores and are not part of the "trusted" list.
222+
//
223+
// The list is refreshed periodically and on SIGHUP, but roots are never
224+
// removed for the lifespan of a log.
225+
//
226+
// Any roots in the ExtraRoots file will be merged with the CCADB roots, and
227+
// the Google Merge Delay Monitor Root will be added automatically.
228+
CCADBRoots string
229+
230+
// ExtraRoots is the path to a PEM file containing extra roots that are
231+
// merged with those fetched from CCADB. It is reloaded from the same path
232+
// when loading roots from CCADB (periodically and on SIGHUP). Optional.
233+
//
234+
// Once added, roots are never removed for the lifespan of a log.
235+
ExtraRoots string
236+
213237
// Secret is the path to a file containing a secret seed from which the
214238
// log's private keys are derived. The file contents are used as HKDF input.
215239
// It must be exactly 32 bytes long.
@@ -532,30 +556,59 @@ func main() {
532556
}
533557
defer l.CloseCache()
534558

535-
reloadRoots := func() error {
536-
rootsPEM, err := os.ReadFile(lc.Roots)
537-
if err != nil {
538-
return err
559+
reloadChan := make(chan os.Signal, 1)
560+
signal.Notify(reloadChan, syscall.SIGHUP)
561+
if lc.Roots != "" {
562+
if lc.CCADBRoots != "" || lc.ExtraRoots != "" {
563+
fatalError(logger, "can't set both Roots and CCADBRoots or ExtraRoots")
539564
}
540-
if err := l.SetRootsFromPEM(ctx, rootsPEM); err != nil {
541-
return err
565+
if err := loadRoots(ctx, lc, l); err != nil {
566+
fatalError(logger, "failed to load Roots", "file", lc.Roots, "err", err)
542567
}
543-
return nil
544-
}
545-
if err := reloadRoots(); err != nil {
546-
fatalError(logger, "failed to load Roots", "file", lc.Roots, "err", err)
547-
}
548-
c := make(chan os.Signal, 1)
549-
signal.Notify(c, syscall.SIGHUP)
550-
go func() {
551-
for range c {
552-
if err := reloadRoots(); err != nil {
553-
logger.Error("failed to reload Roots on SIGHUP", "file", lc.Roots, "err", err)
554-
continue
568+
serveGroup.Go(func() error {
569+
for {
570+
select {
571+
case <-ctx.Done():
572+
return ctx.Err()
573+
case <-reloadChan:
574+
}
575+
if err := loadRoots(ctx, lc, l); err != nil {
576+
logger.Error("failed to reload Roots on SIGHUP", "file", lc.Roots, "err", err)
577+
continue
578+
}
579+
logger.Info("successfully reloaded roots on SIGHUP", "file", lc.Roots)
555580
}
556-
logger.Info("reloaded roots on SIGHUP", "file", lc.Roots)
581+
})
582+
} else {
583+
switch lc.CCADBRoots {
584+
case "trusted", "testing", "":
585+
default:
586+
fatalError(logger, "CCADBRoots must be 'trusted', 'testing', or empty",
587+
"CCADBRoots", lc.CCADBRoots)
557588
}
558-
}()
589+
// We don't run loadCCADBRoots at start, because CCADB is very
590+
// flakey, so we don't want to prevent the log from starting if it's
591+
// down. The previous roots will be loaded by LoadLog anyway.
592+
serveGroup.Go(func() error {
593+
ticker := time.NewTicker(15 * time.Minute)
594+
for {
595+
select {
596+
case <-ctx.Done():
597+
return ctx.Err()
598+
case <-reloadChan:
599+
case <-ticker.C:
600+
}
601+
newRoots, err := loadCCADBRoots(ctx, lc, l)
602+
if err != nil {
603+
logger.Error("failed to reload CCADB roots", "err", err)
604+
continue
605+
}
606+
if newRoots {
607+
logger.Info("successfully loaded new roots from CCADB/ExtraRoots on SIGHUP or timer")
608+
}
609+
}
610+
})
611+
}
559612

560613
period := 1 * time.Second
561614
if lc.Period > 0 {

internal/ctlog/ctlog.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ func CreateLog(ctx context.Context, config *Config) error {
152152
return fmt.Errorf("couldn't upload checkpoint: %w", err)
153153
}
154154

155+
if err := config.Backend.Upload(ctx, "_roots.pem", []byte(""), optsRoots); err != nil {
156+
return fmt.Errorf("couldn't upload roots: %w", err)
157+
}
158+
155159
config.Log.InfoContext(ctx, "created log", "timestamp", timestamp,
156160
"logID", base64.StdEncoding.EncodeToString(logID[:]))
157161
return nil
@@ -284,7 +288,15 @@ func LoadLog(ctx context.Context, config *Config) (*Log, error) {
284288
}
285289

286290
roots := x509util.NewPEMCertPool()
287-
rootsPEM := []byte("")
291+
rootsPEM, err := config.Backend.Fetch(ctx, "_roots.pem")
292+
if err != nil {
293+
config.Log.WarnContext(ctx, "failed to fetch previously trusted roots", "err", err)
294+
} else {
295+
if !roots.AppendCertsFromPEM(rootsPEM) {
296+
config.Log.WarnContext(ctx, "failed to parse previously trusted roots",
297+
"roots", string(rootsPEM))
298+
}
299+
}
288300

289301
config.Log.InfoContext(ctx, "loaded log", "logID", base64.StdEncoding.EncodeToString(logID[:]),
290302
"size", c.N, "timestamp", timestamp)
@@ -388,6 +400,9 @@ func (l *Log) SetRootsFromPEM(ctx context.Context, pemBytes []byte) error {
388400
}
389401
l.rootsMu.Lock()
390402
defer l.rootsMu.Unlock()
403+
if err := l.c.Backend.Upload(ctx, "_roots.pem", pemBytes, optsRoots); err != nil {
404+
return fmt.Errorf("couldn't upload roots: %w", err)
405+
}
391406
l.m.ConfigRoots.Set(float64(len(roots.RawCertificates())))
392407
l.roots = roots
393408
l.rootsPEM = bytes.Clone(pemBytes)
@@ -437,6 +452,7 @@ var optsDataTile = &UploadOptions{Compressed: true, Immutable: true}
437452
var optsStaging = &UploadOptions{Compressed: true, Immutable: true}
438453
var optsIssuer = &UploadOptions{ContentType: "application/pkix-cert", Immutable: true}
439454
var optsCheckpoint = &UploadOptions{ContentType: "text/plain; charset=utf-8"}
455+
var optsRoots = &UploadOptions{ContentType: "application/x-pem-file"}
440456

441457
var ErrLogNotFound = errors.New("log not found")
442458

internal/ctlog/ctlog_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ func TestSequenceUploadPaths(t *testing.T) {
183183
slices.Sort(keys)
184184

185185
expected := []string{
186+
"_roots.pem",
186187
"checkpoint",
187188
"issuer/1b48a2acbba79932d3852ccde41197f678256f3c2a280e9edf9aad272d6e9c92",
188189
"issuer/559aead08264d5795d3909718cdd05abd49572e84fe55590eef31a88a08fdffd",
@@ -209,7 +210,7 @@ func TestSequenceUploadPaths(t *testing.T) {
209210
for _, key := range keys {
210211
expectedImmutable := false
211212
expectedDeleted := false
212-
if key != "checkpoint" {
213+
if key != "checkpoint" && key != "_roots.pem" {
213214
expectedImmutable = true
214215
}
215216
if strings.HasPrefix(key, "staging/") {

0 commit comments

Comments
 (0)