Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ This database must be global and it **must never be changed or modified** as an
The database must already exist, to prevent misconfigurations. Create it with

```
sqlite3 checkpoints.db "CREATE TABLE checkpoints (logID BLOB PRIMARY KEY, body TEXT)"
sqlite3 checkpoints.db "CREATE TABLE checkpoints (logID BLOB PRIMARY KEY, body BLOB NOT NULL) STRICT"
```

Sunlight can alternatively use DynamoDB or S3-compatible object storage with `ETag` and `If-Match` support (such as Tigris) as global lock backends.
Expand Down
17 changes: 17 additions & 0 deletions cmd/sunlight-keygen/keygen.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"filippo.io/keygen"
"filippo.io/sunlight/internal/immutable"
"filippo.io/torchwood"
"golang.org/x/crypto/hkdf"
"golang.org/x/mod/sumdb/note"
)
Expand All @@ -25,6 +26,7 @@ func main() {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
fileFlag := fs.String("f", "", "path to the seed file")
prefixFlag := fs.String("prefix", "", "submission prefix for the log, to output a witness verifier key")
witnessFlag := fs.String("witness", "", "witness name, for generating a witness secret instead")
fs.Parse(os.Args[1:])
if fs.NArg() != 0 || *fileFlag == "" {
fmt.Fprintln(os.Stderr, "usage: sunlight-keygen -f <seed file>")
Expand Down Expand Up @@ -57,6 +59,21 @@ func main() {
log.Fatal("seed file must be exactly 32 bytes")
}

if *witnessFlag != "" {
ed25519Secret := make([]byte, ed25519.SeedSize)
if _, err := io.ReadFull(hkdf.New(sha256.New, seed, []byte("sunlight Ed25519 witness key"),
[]byte(*witnessFlag)), ed25519Secret); err != nil {
log.Fatal("failed to derive Ed25519 key:", err)
}
wk := ed25519.NewKeyFromSeed(ed25519Secret)
s, err := torchwood.NewCosignatureSigner(*witnessFlag, wk)
if err != nil {
log.Fatal("failed to create witness signer:", err)
}
fmt.Printf("Witness vkey: %s\n", s.Verifier())
return
}

ecdsaSecret := make([]byte, 32)
if _, err := io.ReadFull(hkdf.New(sha256.New, seed, []byte("sunlight"), []byte("ECDSA P-256 log key")), ecdsaSecret); err != nil {
log.Fatal("failed to derive ECDSA secret:", err)
Expand Down
53 changes: 33 additions & 20 deletions cmd/sunlight/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,6 @@

<p>
<a href="metrics">Metrics</a> are available.

{{ if .Witness.Name }}
<hr>

<p>
The following witness is active.

<h2>{{ .Witness.Name }}</h2>

<p>
Submission prefix: <code>{{ .Witness.SubmissionPrefix }}</code><br>
Known logs:
<ul>
{{ range .Witness.Logs }}
<li><code>{{ . }}</code></li>
{{ end }}
</ul>

<pre><code>{{ .Witness.VerifierKey }}</code></pre>
{{ end }}

<hr>

Expand Down Expand Up @@ -103,6 +83,39 @@ <h3>Submit a certificate chain (PEM or JSON)</h3>

{{ end }}

{{ if .Witness.Name }}
<hr>

<p>
The following witness is active.

<h2>{{ .Witness.Name }}</h2>

<p>
Submission prefix: <code>{{ .Witness.SubmissionPrefix }}</code>

<p>
Verifier key:

<pre><code>{{ .Witness.VerifierKey }}</code></pre>

<p>
Log list sources:
<ul>
{{ range .Witness.LogLists }}
<li><a href="{{ . }}">{{ . }}</a></li>
{{ end }}
</ul>

<p>
Known logs:
<ul>
{{ range .Witness.Logs }}
<li><code>{{ . }}</code></li>
{{ end }}
</ul>
{{ end }}

<script>
for (const fileInput of document.querySelectorAll('.chain')) {
fileInput.addEventListener('change', async (event) => {
Expand Down
97 changes: 67 additions & 30 deletions cmd/sunlight/sunlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ type Config struct {
// The database must already exist to protect against accidental
// misconfiguration. Create the table with:
//
// $ sqlite3 checkpoints.db "CREATE TABLE checkpoints (logID BLOB PRIMARY KEY, body TEXT)"
// $ sqlite3 checkpoints.db "CREATE TABLE checkpoints (logID BLOB PRIMARY KEY, body BLOB NOT NULL) STRICT"
//
Checkpoints string

Expand Down Expand Up @@ -133,7 +133,8 @@ type Config struct {
Name string

// SubmissionPrefix is the full URL of the c2sp.org/tlog-witness
// submission prefix of the witness.
// submission prefix of the witness. If not set, it defaults to
// "https://" + Name.
//
// The HTTP server will serve the witness at this URL, and if ACME is
// enabled, Sunlight will obtain a certificate for the host of this URL.
Expand All @@ -145,13 +146,15 @@ type Config struct {
//
// To generate a new seed, run:
//
// $ sunlight-keygen -f seed.bin
// $ sunlight-keygen -f seed.bin -witness <name>
//
Secret string

// KnownLogs is a list of known logs that the witness will accept and
// cosign checkpoints for, along with their vkeys.
KnownLogs []witness.LogConfig
// LogList are the URLs of witness network log list.
//
// Logs are pulled from the lists on startup and every 15 minutes. The
// lists can only add logs, never remove them or change their public key.
LogLists []string
}

Logs []LogConfig
Expand Down Expand Up @@ -413,18 +416,24 @@ func main() {

sequencerGroup, sequencerContext := errgroup.WithContext(ctx)

var homeData struct {
Logs []logInfo
Witness struct {
Name string
SubmissionPrefix string
VerifierKey string
Logs []string
}
var homeLogsInfo []logInfo
type witnessInfo struct {
Name string
SubmissionPrefix string
VerifierKey string
LogLists []string
Logs []string
}
homeWitnessInfo := func() witnessInfo { return witnessInfo{} }
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
if err := homeTmpl.Execute(w, homeData); err != nil {
if err := homeTmpl.Execute(w, struct {
Logs []logInfo
Witness witnessInfo
}{
Logs: homeLogsInfo,
Witness: homeWitnessInfo(),
}); err != nil {
logger.Error("failed to execute homepage template", "err", err)
}
})
Expand Down Expand Up @@ -586,10 +595,12 @@ func main() {
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 {
if newRoots, err := loadCCADBRoots(ctx, lc, l); err != nil {
logger.Error("failed to load initial CCADB roots", "err", err)
} else if newRoots {
logger.Info("successfully loaded new roots from CCADB/ExtraRoots")
}
ticker := time.NewTicker(15 * time.Minute)
for {
select {
Expand All @@ -598,12 +609,9 @@ func main() {
case <-reloadChan:
case <-ticker.C:
}
newRoots, err := loadCCADBRoots(ctx, lc, l)
if err != nil {
if newRoots, err := loadCCADBRoots(ctx, lc, l); err != nil {
logger.Error("failed to reload CCADB roots", "err", err)
continue
}
if newRoots {
} else if newRoots {
logger.Info("successfully loaded new roots from CCADB/ExtraRoots on SIGHUP or timer")
}
}
Expand Down Expand Up @@ -644,7 +652,7 @@ func main() {
}
log.Interval.NotAfterStart = lc.NotAfterStart
log.Interval.NotAfterLimit = lc.NotAfterLimit
homeData.Logs = append(homeData.Logs, log)
homeLogsInfo = append(homeLogsInfo, log)

j, err := json.MarshalIndent(log, "", " ")
if err != nil {
Expand Down Expand Up @@ -685,12 +693,38 @@ func main() {
Key: wk,
Backend: db,
Log: logger,
Logs: c.Witness.KnownLogs,
})
if err != nil {
fatalError(logger, "failed to create witness", "err", err)
}

reloadChan := make(chan os.Signal, 1)
signal.Notify(reloadChan, syscall.SIGHUP)
serveGroup.Go(func() error {
for _, url := range c.Witness.LogLists {
if err := w.PullLogList(ctx, url); err != nil {
logger.Error("failed to pull log list", "list", url, "err", err)
}
}
ticker := time.NewTicker(15 * time.Minute)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-reloadChan:
case <-ticker.C:
}
for _, url := range c.Witness.LogLists {
if err := w.PullLogList(ctx, url); err != nil {
logger.Error("failed to pull log list", "list", url, "err", err)
}
}
}
})

if c.Witness.SubmissionPrefix == "" {
c.Witness.SubmissionPrefix = "https://" + c.Witness.Name
}
c.Witness.SubmissionPrefix = strings.TrimSuffix(c.Witness.SubmissionPrefix, "/")
prefix, err := url.Parse(c.Witness.SubmissionPrefix)
if err != nil {
Expand All @@ -711,11 +745,14 @@ func main() {
witnessMetrics := prometheus.WrapRegistererWithPrefix("witness_", sunlightMetrics)
witnessMetrics.MustRegister(w.Metrics()...)

homeData.Witness.Name = c.Witness.Name
homeData.Witness.SubmissionPrefix = c.Witness.SubmissionPrefix
homeData.Witness.VerifierKey = w.VerifierKey()
for _, log := range c.Witness.KnownLogs {
homeData.Witness.Logs = append(homeData.Witness.Logs, log.Origin)
homeWitnessInfo = func() witnessInfo {
return witnessInfo{
Name: c.Witness.Name,
SubmissionPrefix: c.Witness.SubmissionPrefix,
VerifierKey: w.VerifierKey(),
LogLists: c.Witness.LogLists,
Logs: w.Logs(),
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions internal/ctlog/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ func initCache(path string) (readConn, writeConn *sqlite.Conn, err error) {
if err := sqlitex.ExecTransient(writeConn, `
CREATE TABLE IF NOT EXISTS cache (
key BLOB PRIMARY KEY,
timestamp INTEGER,
leaf_index INTEGER
) WITHOUT ROWID;`, nil); err != nil {
timestamp INTEGER NOT NULL,
leaf_index INTEGER NOT NULL
) WITHOUT ROWID, STRICT;`, nil); err != nil {
writeConn.Close()
return nil, nil, err
}
Expand Down
11 changes: 10 additions & 1 deletion internal/ctlog/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func NewSQLiteBackend(ctx context.Context, path string, l *slog.Logger) (*SQLite

conn, err := sqlite.OpenConn(path, sqlite.OpenFlagsDefault & ^sqlite.SQLITE_OPEN_CREATE)
if err != nil {
return nil, fmt.Errorf(`failed to open SQLite lock database (hint: to avoid misconfiguration, the lock database must be created manually with "CREATE TABLE checkpoints (logID BLOB PRIMARY KEY, body TEXT)"): %w`, err)
return nil, fmt.Errorf(`failed to open SQLite lock database (hint: to avoid misconfiguration, the lock database must be created manually with "CREATE TABLE checkpoints (logID BLOB PRIMARY KEY, body BLOB NOT NULL) STRICT"): %w`, err)
}
if err := sqlitex.ExecTransient(conn, "PRAGMA synchronous = FULL", nil); err != nil {
conn.Close()
Expand Down Expand Up @@ -86,13 +86,18 @@ func (b *SQLiteBackend) Replace(ctx context.Context, old LockedCheckpoint, new [
defer prometheus.NewTimer(b.duration).ObserveDuration()
b.mu.Lock()
defer b.mu.Unlock()
if new == nil {
// NULL does *not* compare equal to an empty blob!
new = []byte{}
}
o := old.(*sqliteCheckpoint)
err := sqlitex.Exec(b.conn, "UPDATE checkpoints SET body = ? WHERE logID = ? AND body = ?",
nil, new, o.logID[:], o.body)
if err != nil {
return nil, fmtErrorf("failed to update SQLite checkpoint: %w", err)
}
if b.conn.Changes() == 0 {
// TODO: this also hits if new == old.body, and shouldn't.
return nil, fmtErrorf("SQLite checkpoint not found or has changed")
}
return &sqliteCheckpoint{logID: o.logID, body: new}, nil
Expand All @@ -101,6 +106,10 @@ func (b *SQLiteBackend) Replace(ctx context.Context, old LockedCheckpoint, new [
func (b *SQLiteBackend) Create(ctx context.Context, logID [sha256.Size]byte, new []byte) error {
b.mu.Lock()
defer b.mu.Unlock()
if new == nil {
// NULL does *not* compare equal to an empty blob!
new = []byte{}
}
err := sqlitex.Exec(b.conn, `INSERT INTO checkpoints (logID, body) VALUES (?, ?)
ON CONFLICT(logID) DO NOTHING`, nil, logID[:], new)
if err != nil {
Expand Down
26 changes: 23 additions & 3 deletions internal/witness/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,37 @@ import (
"github.com/prometheus/client_golang/prometheus"
)

// TODO: add-checkpoint metrics partitioned by origin, outcome.
// TODO: log size gauge partitioned by origin.

type metrics struct {
KnownLogs prometheus.Gauge
LogSize *prometheus.GaugeVec
AddCheckpointCount *prometheus.CounterVec

ReqCount *prometheus.CounterVec
ReqInFlight *prometheus.GaugeVec
ReqDuration *prometheus.SummaryVec
}

func initMetrics() metrics {
return metrics{
KnownLogs: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "known_logs",
Help: "Number of logs known to the witness.",
}),
LogSize: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "log_entries_total",
Help: "Size of the latest checkpoint, by log origin.",
},
[]string{"origin"},
),
AddCheckpointCount: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "add_checkpoint_requests_total",
Help: "Total number of add-checkpoint requests processed, by log origin.",
},
[]string{"error", "origin", "progress"},
),

ReqInFlight: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "http_in_flight_requests",
Expand Down
Loading