Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions cmd/sunlight/roots.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package main

import (
"bytes"
"context"
"encoding/csv"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"time"

"filippo.io/sunlight/internal/ctlog"
"github.com/google/certificate-transparency-go/x509"
"github.com/google/certificate-transparency-go/x509util"
)

func loadRoots(ctx context.Context, lc LogConfig, l *ctlog.Log) error {
rootsPEM, err := os.ReadFile(lc.Roots)
if err != nil {
return err
}
if err := l.SetRootsFromPEM(ctx, rootsPEM); err != nil {
return err
}
return nil
}

func loadCCADBRoots(ctx context.Context, lc LogConfig, l *ctlog.Log) (newRoots bool, err error) {
old := l.RootsPEM()
buf := bytes.NewBuffer(old)
pool := x509util.NewPEMCertPool()
pool.AppendCertsFromPEM(old)
addRoot := func(cert *x509.Certificate, source string) {
if pool.Included(cert) {
return
}
newRoots = true
pool.AddCert(cert)
fmt.Fprintf(buf, "\n# %s\n# added on %s from %s\n%s\n",
cert.Subject.String(),
time.Now().Format(time.RFC3339),
source,
pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}),
)
}

mergeDelayCert, err := x509util.CertificateFromPEM([]byte(mergeDelayRoot))
if err != nil {
return false, fmt.Errorf("failed to parse merge delay root: %w", err)
}
addRoot(mergeDelayCert, "Sunlight")

url := "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesIncludedByRSReportCSV"
if lc.CCADBRoots == "testing" {
url = "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesInclusionReportCSV"
}
certs, err := CCADBRoots(ctx, url)
if err != nil {
return false, err
}
for _, cert := range certs {
addRoot(cert, "CCADB")
}

if lc.ExtraRoots != "" {
extraBytes, err := os.ReadFile(lc.ExtraRoots)
if err != nil {
return false, fmt.Errorf("failed to read extra roots file %q: %w", lc.ExtraRoots, err)
}
extra, err := x509util.CertificatesFromPEM(extraBytes)
if err != nil {
return false, fmt.Errorf("failed to parse extra roots file %q: %w", lc.ExtraRoots, err)
}
for _, cert := range extra {
addRoot(cert, "extra roots file")
}
}

if !newRoots {
return false, nil
}
return true, l.SetRootsFromPEM(ctx, buf.Bytes())
}

var CCADBClient = &http.Client{
Timeout: 10 * time.Second,
}

func CCADBRoots(ctx context.Context, url string) ([]*x509.Certificate, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "+https://filippo.io/sunlight")
resp, err := CCADBClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch CCADB CSV: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch CCADB CSV: %s", resp.Status)
}

csvReader := csv.NewReader(resp.Body)
hdr, err := csvReader.Read()
if err != nil {
return nil, fmt.Errorf("failed to read CCADB CSV header: %w", err)
}
pemIdx := slices.Index(hdr, "X.509 Certificate (PEM)")
if pemIdx < 0 {
return nil, fmt.Errorf("CCADB CSV header does not contain %q", "X.509 Certificate (PEM)")
}
usesIdx := slices.Index(hdr, "Intended Use Case(s) Served")
if usesIdx < 0 {
return nil, fmt.Errorf("CCADB CSV header does not contain %q", "Intended Use Case(s) Served")
}
var certificates []*x509.Certificate
for {
row, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read CCADB CSV row: %w", err)
}
if len(row) <= pemIdx || len(row) <= usesIdx {
return nil, fmt.Errorf("CCADB CSV row is too short: %q", row)
}
// There is an "Example CA" row with an empty PEM column.
if row[pemIdx] == "" {
continue
}
if !strings.Contains(row[usesIdx], "Server Authentication (TLS) 1.3.6.1.5.5.7.3.1") {
continue
}
cert, err := x509util.CertificateFromPEM([]byte(row[pemIdx]))
if err != nil {
return nil, fmt.Errorf("failed to parse CCADB certificate: %w\n%q", err, row)
}
certificates = append(certificates, cert)
}
if len(certificates) == 0 {
return nil, fmt.Errorf("no certificates found in CCADB CSV")
}
return certificates, nil
}

const mergeDelayRoot = `
-----BEGIN CERTIFICATE-----
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
eg==
-----END CERTIFICATE-----
`
25 changes: 25 additions & 0 deletions cmd/sunlight/roots_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package main

import "testing"

func TestCCADBRoots(t *testing.T) {
t.Run("Prod", func(t *testing.T) {
url := "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesIncludedByRSReportCSV"
testCCADBRoots(t, url)
})
t.Run("Testing", func(t *testing.T) {
url := "https://ccadb.my.salesforce-sites.com/ccadb/RootCACertificatesInclusionReportCSV"
testCCADBRoots(t, url)
})
}

func testCCADBRoots(t *testing.T, url string) {
certs, err := CCADBRoots(t.Context(), url)
if err != nil {
t.Fatalf("failed to load CCADB roots: %v", err)
}
if len(certs) < 50 {
t.Fatalf("expected at least 50 CCADB roots, got %d", len(certs))
}
t.Logf("loaded %d CCADB roots", len(certs))
}
94 changes: 84 additions & 10 deletions cmd/sunlight/sunlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"os"
"os/signal"
"strings"
"syscall"
"text/template"
"time"

Expand All @@ -47,7 +48,6 @@ import (
"filippo.io/sunlight/internal/reused"
"filippo.io/sunlight/internal/stdlog"
"filippo.io/sunlight/internal/witness"
"github.com/google/certificate-transparency-go/x509util"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
Expand Down Expand Up @@ -145,7 +145,7 @@ type Config struct {
//
// To generate a new seed, run:
//
// $ head -c 32 /dev/urandom > seed.bin
// $ sunlight-keygen -f seed.bin
//
Secret string

Expand Down Expand Up @@ -205,16 +205,42 @@ type LogConfig struct {
// prefix of the log.
MonitoringPrefix string

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

// CCADBRoots can be "trusted" or "testing". Optional. It defaults to
// "trusted" if Roots is not set.
//
// If "trusted", the log will fetch accepted roots from the CCADB list of
// CAs trusted by at least one of the CCADB root stores. If "testing", the
// log will fetch CAs that have applied to at least one of the CCADB root
// stores and are not part of the "trusted" list.
//
// The list is refreshed periodically and on SIGHUP, but roots are never
// removed for the lifespan of a log.
//
// Any roots in the ExtraRoots file will be merged with the CCADB roots, and
// the Google Merge Delay Monitor Root will be added automatically.
CCADBRoots string

// ExtraRoots is the path to a PEM file containing extra roots that are
// merged with those fetched from CCADB. It is reloaded from the same path
// when loading roots from CCADB (periodically and on SIGHUP). Optional.
//
// Once added, roots are never removed for the lifespan of a log.
ExtraRoots string

// Secret is the path to a file containing a secret seed from which the
// log's private keys are derived. The file contents are used as HKDF input.
// It must be exactly 32 bytes long.
//
// To generate a new seed, run:
//
// $ head -c 32 /dev/urandom > seed.bin
// $ sunlight-keygen -f seed.bin
//
Secret string

Expand Down Expand Up @@ -430,11 +456,6 @@ func main() {
fatalError(logger, "neither S3Bucket nor LocalDirectory are set, one must be used")
}

r := x509util.NewPEMCertPool()
if err := r.AppendCertsFromPEMFile(lc.Roots); err != nil {
fatalError(logger, "failed to load roots", "err", err)
}

if lc.Secret == "" && lc.Seed != "" {
logger.Warn("using deprecated Seed field, use Secret instead")
lc.Secret = lc.Seed
Expand Down Expand Up @@ -513,7 +534,6 @@ func main() {
Backend: b,
Lock: db,
Log: logger,
Roots: r,
NotAfterStart: notAfterStart,
NotAfterLimit: notAfterLimit,
}
Expand All @@ -536,6 +556,60 @@ func main() {
}
defer l.CloseCache()

reloadChan := make(chan os.Signal, 1)
signal.Notify(reloadChan, syscall.SIGHUP)
if lc.Roots != "" {
if lc.CCADBRoots != "" || lc.ExtraRoots != "" {
fatalError(logger, "can't set both Roots and CCADBRoots or ExtraRoots")
}
if err := loadRoots(ctx, lc, l); err != nil {
fatalError(logger, "failed to load Roots", "file", lc.Roots, "err", err)
}
serveGroup.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-reloadChan:
}
if err := loadRoots(ctx, lc, l); err != nil {
logger.Error("failed to reload Roots on SIGHUP", "file", lc.Roots, "err", err)
continue
}
logger.Info("successfully reloaded roots on SIGHUP", "file", lc.Roots)
}
})
} else {
switch lc.CCADBRoots {
case "trusted", "testing", "":
default:
fatalError(logger, "CCADBRoots must be 'trusted', 'testing', or empty",
"CCADBRoots", lc.CCADBRoots)
}
// We don't run loadCCADBRoots at start, because CCADB is very
// flakey, so we don't want to prevent the log from starting if it's
// down. The previous roots will be loaded by LoadLog anyway.
serveGroup.Go(func() error {
ticker := time.NewTicker(15 * time.Minute)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-reloadChan:
case <-ticker.C:
}
newRoots, err := loadCCADBRoots(ctx, lc, l)
if err != nil {
logger.Error("failed to reload CCADB roots", "err", err)
continue
}
if newRoots {
logger.Info("successfully loaded new roots from CCADB/ExtraRoots on SIGHUP or timer")
}
}
})
}

period := 1 * time.Second
if lc.Period > 0 {
period = time.Duration(lc.Period) * time.Millisecond
Expand Down
Loading