Skip to content

Commit 3ebcb61

Browse files
authored
mtca: write to tiles (#8900)
This wires up tiles.Frontier to the MTCA's initialization and sequencing code paths. As of this change we are writing correctly-calculated RootHashes into the DB and properly-formed TBSCertificateLogEntry (wrapped in MTCLogEntry) into the tiles, along with all the hashes needed. Add a `Preflight()` that loads the frontier into memory before `Loop()` starts. Add `Frontier.Clone()` so `sequence()` can create a working copy but not reassign its in-memory state until signing and storing the checkpoint succeeds. Replace Frontier.Flush(), with two methods `Stage()` and `Publish()` to write to a pending area and to the live area, respectively. This is not a full implementation of #8902. In particular we will still need code to recover state from the pending area on startup. And we should refine `Publish()` to use `CopyObject` instead of writing the same tiles from scratch. Move fake S3 into its own package, bs3/bs3test. Unittests written by Claude, reviewed and edited by me.
1 parent d1f4909 commit 3ebcb61

8 files changed

Lines changed: 701 additions & 196 deletions

File tree

bs3/bs3test/bs3test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Package bs3test provides an in-memory fake of the S3 client subset
2+
// provided by the bs3 package, for use in tests.
3+
package bs3test
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"sync"
13+
14+
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
15+
"github.com/aws/aws-sdk-go-v2/service/s3"
16+
smithyhttp "github.com/aws/smithy-go/transport/http"
17+
)
18+
19+
// StoredObject is an object held by a FakeS3.
20+
type StoredObject struct {
21+
Data []byte
22+
ContentEncoding *string
23+
}
24+
25+
// FakeS3 implements the PutObject/GetObject/Bucket subset of the S3 API
26+
// used by the tiles package with an in-memory map.
27+
//
28+
// Calling PutObject twice for the same key result in an error.
29+
type FakeS3 struct {
30+
mu sync.Mutex
31+
32+
Objects map[string]StoredObject
33+
}
34+
35+
func New() *FakeS3 {
36+
return &FakeS3{Objects: make(map[string]StoredObject)}
37+
}
38+
39+
func (f *FakeS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
40+
f.mu.Lock()
41+
defer f.mu.Unlock()
42+
_, ok := f.Objects[*params.Key]
43+
if ok {
44+
return nil, &awshttp.ResponseError{
45+
ResponseError: &smithyhttp.ResponseError{
46+
Response: &smithyhttp.Response{Response: &http.Response{StatusCode: http.StatusPreconditionFailed}},
47+
Err: errors.New("PreconditionFailed"),
48+
},
49+
}
50+
}
51+
body, err := io.ReadAll(params.Body)
52+
if err != nil {
53+
return nil, err
54+
}
55+
f.Objects[*params.Key] = StoredObject{Data: body, ContentEncoding: params.ContentEncoding}
56+
return nil, nil
57+
}
58+
59+
func (f *FakeS3) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
60+
f.mu.Lock()
61+
defer f.mu.Unlock()
62+
o, ok := f.Objects[*params.Key]
63+
if ok {
64+
return &s3.GetObjectOutput{
65+
Body: io.NopCloser(bytes.NewReader(o.Data)),
66+
ContentEncoding: o.ContentEncoding,
67+
}, nil
68+
}
69+
return nil, fmt.Errorf("not found")
70+
}
71+
72+
func (f *FakeS3) Bucket() string {
73+
return "fakebucket"
74+
}

cmd/boulder-mtca/main.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,21 @@ func main() {
129129
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
130130
defer cancel()
131131
err = mtcaImpl.InitLog(ctx)
132-
cmd.FailOnError(err, "Initializing log")
132+
cmd.FailOnError(err, "Initializing MTC issuance log")
133133
return
134134
}
135135
if *initLogForTest {
136136
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
137137
defer cancel()
138138
err = mtcaImpl.InitLog(ctx)
139139
if err != nil && !errors.Is(err, mtca.ErrIssuanceLogAlreadyInitialized) {
140-
cmd.FailOnError(err, "Initializing MTC log DB for test")
140+
cmd.FailOnError(err, "Initializing MTC issuance log for test")
141141
}
142142
}
143143

144+
err = mtcaImpl.Preflight(context.Background())
145+
cmd.FailOnError(err, "Loading log state")
146+
144147
srv := bgrpc.NewServer(c.MTCA.GRPCMTCA, logger).Add(
145148
&mtcapb.MTCA_ServiceDesc, mtcaImpl)
146149

mtca/mtca.go

Lines changed: 135 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
package mtca
44

55
import (
6+
"bytes"
67
"context"
78
"crypto"
8-
"crypto/rand"
99
"crypto/sha256"
1010
"crypto/x509"
1111
"database/sql"
1212
"encoding/asn1"
13+
"encoding/base64"
14+
"encoding/hex"
1315
"errors"
1416
"fmt"
1517
"sync"
@@ -26,6 +28,7 @@ import (
2628
mtcapb "github.com/letsencrypt/boulder/mtca/proto"
2729
"github.com/letsencrypt/boulder/trees/cosigned"
2830
"github.com/letsencrypt/boulder/trees/entry"
31+
"github.com/letsencrypt/boulder/trees/tiles"
2932
)
3033

3134
var ErrIssuanceLogAlreadyInitialized = errors.New("issuance log already initialized")
@@ -79,6 +82,11 @@ type mtca struct {
7982
logNumber uint16
8083
pool *pool
8184

85+
// frontier contains all the tiles on the right edge of the tree.
86+
// It will be used to accumulate entries for writing to storage.
87+
// Not safe for concurrent reading and writing.
88+
frontier *tiles.Frontier
89+
8290
sequencingPeriod time.Duration
8391

8492
// TODO: factor our sa.InitWrappedDb() so we get metrics and other goodies.
@@ -122,7 +130,17 @@ func initDB(dbMap *borp.DbMap) *db.WrappedMap {
122130
// InitLog creates the database metadata for a new, empty log: one checkpoint and the row
123131
// in `latestCheckpoint` that refers to it. Should only be run once in a log's lifetime.
124132
func (m *mtca) InitLog(ctx context.Context) error {
125-
_, err := db.WithTransaction(ctx, m.db, func(tx db.Executor) (any, error) {
133+
candidate := &tiles.Frontier{}
134+
135+
nullEntry := &entry.MTCLogEntry{}
136+
err := candidate.AppendEntry(nullEntry)
137+
if err != nil {
138+
return err
139+
}
140+
141+
rootHash := candidate.RootHash()
142+
143+
_, err = db.WithTransaction(ctx, m.db, func(tx db.Executor) (any, error) {
126144
var numLatestCheckpoints int64
127145
err := tx.SelectOne(ctx, &numLatestCheckpoints, "SELECT COUNT(*) FROM latestCheckpoint WHERE mtcLogID = ?",
128146
m.mtcLogID())
@@ -146,19 +164,9 @@ func (m *mtca) InitLog(ctx context.Context) error {
146164
m.mtcLogID(), numCheckpoints, numLatestCheckpoints)
147165
}
148166

149-
// null_entry has empty extensions and a MTCLogEntryType of 0. Since extensions can be up to 2^16 long
150-
// there's two bytes of length prefix. Since MTCLogEntryType can have up to 2^16 values, it's also two bytes.
151-
// All the bytes are zero: empty extensions, null_entry type is enum value zero.
152-
// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-log-entries
153-
// To calculate the Merkle Tree Hash of a single-entry list, we prepend 0x00 (as compared with 0x01 when hashing
154-
// two nodes). So five zeroes total.
155-
// https://www.rfc-editor.org/info/rfc9162/#name-definition-of-the-merkle-tr
156-
nullEntry := []byte{0, 0, 0, 0, 0}
157-
rootHash := sha256.Sum256(nullEntry)
158-
159167
firstCheckpoint := checkpoint{
160168
MTCLogID: m.mtcLogID(),
161-
TreeSize: 1,
169+
TreeSize: candidate.TreeSize(),
162170
RootHash: rootHash[:],
163171
}
164172

@@ -188,9 +196,24 @@ func (m *mtca) InitLog(ctx context.Context) error {
188196
return nil, nil
189197
})
190198
if err != nil {
199+
if errors.Is(err, ErrIssuanceLogAlreadyInitialized) {
200+
// The DB thinks the log is initialized; make sure the tiles are there.
201+
err2 := m.Preflight(ctx)
202+
if err2 != nil {
203+
return fmt.Errorf("DB is initialized but Preflight returns: %s", err2)
204+
}
205+
return err
206+
}
191207
return err
192208
}
193209

210+
err = candidate.Publish(ctx, m.s3c, m.tileStoragePrefix())
211+
if err != nil {
212+
return err
213+
}
214+
215+
m.frontier = candidate
216+
194217
_, err = m.latestCheckpoint(ctx)
195218
if err != nil {
196219
return fmt.Errorf("fetching first checkpoint: %s", err)
@@ -199,13 +222,37 @@ func (m *mtca) InitLog(ctx context.Context) error {
199222
return err
200223
}
201224

225+
// Preflight gets the latest checkpoint from the database and reads the corresponding
226+
// frontier tiles from storage. It must be called on startup, before Loop().
227+
func (m *mtca) Preflight(ctx context.Context) error {
228+
latest, err := m.latestCheckpoint(ctx)
229+
if err != nil {
230+
return err
231+
}
232+
frontier, err := tiles.LoadFrontier(ctx, m.s3c, latest.TreeSize, m.tileStoragePrefix())
233+
if err != nil {
234+
return err
235+
}
236+
237+
tileBasedHash := frontier.RootHash()
238+
if !bytes.Equal(latest.RootHash, tileBasedHash[:]) {
239+
return fmt.Errorf("state mismatch: at tree size %d, DB contains RootHash %s, but frontier tiles calculate %s",
240+
latest.TreeSize,
241+
base64.StdEncoding.EncodeToString(latest.RootHash[:]),
242+
tileBasedHash)
243+
}
244+
245+
m.frontier = frontier
246+
return nil
247+
}
248+
202249
type pool struct {
203250
sync.RWMutex
204251
entries []pendingEntry
205252
maxSize int
206253
}
207254

208-
// pendingEntry represents an pending entry in the pool, along with a channel to notify a pending RPC.
255+
// pendingEntry represents a pending entry in the pool, along with a channel to notify a pending RPC.
209256
type pendingEntry struct {
210257
mtcle *entry.MTCLogEntry
211258
ch chan<- int64
@@ -242,8 +289,29 @@ func (m *mtca) mtcLogID() string {
242289
return fmt.Sprintf("%s.0.%d", m.mtcaID, m.logNumber)
243290
}
244291

292+
// tileStoragePrefix returns the path within a bucket where we will store tiles.
293+
//
294+
// https://github.com/C2SP/C2SP/blob/main/mtc-tlog.md#serving-issuance-logs
295+
//
296+
// "Each log's prefix URL is the concatenation of the CA prefix URL and the log number, encoded as an
297+
// ASCII decimal integer with no additional leading zeros:
298+
//
299+
// <CA prefix URL>/<log number>"
300+
//
301+
// We assume we will serve directly from tile storage (likely sync'ed somewhere), so we
302+
// want to store tiles compatible with that pattern, with log number as the last component
303+
// of the path before "tile/".
304+
//
305+
// As a matter of local convention we will put the MTCA ID as the path component right before
306+
// log number.
307+
func (m *mtca) tileStoragePrefix() string {
308+
return fmt.Sprintf("%s/%d", m.mtcaID, m.logNumber)
309+
}
310+
245311
// Issue requests a TBSCertificateLogEntry be issued and returns after it's been sequenced into the log
246312
// and a new checkpoint signed by the CA. It does not wait for a mirror cosignature.
313+
//
314+
// Safe for concurrent calls. Implements a gRPC method.
247315
func (m *mtca) Issue(ctx context.Context, req *mtcapb.IssueRequest) (*mtcapb.IssueResponse, error) {
248316
key, err := x509.ParsePKIXPublicKey(req.Pubkey)
249317
if err != nil {
@@ -279,7 +347,7 @@ func (m *mtca) Issue(ctx context.Context, req *mtcapb.IssueRequest) (*mtcapb.Iss
279347

280348
mtcle, err := entry.FromX509(lintCertBytes, crypto.SHA256)
281349
if err != nil {
282-
return nil, fmt.Errorf("generating MTCLogEntry: %s '%x'", err, lintCertBytes)
350+
return nil, fmt.Errorf("generating MTCLogEntry: %s", err)
283351
}
284352

285353
// We'll get notification of sequencing on this channel. Buffer it so `sequence()` doesn't
@@ -309,6 +377,8 @@ func (m *mtca) Issue(ctx context.Context, req *mtcapb.IssueRequest) (*mtcapb.Iss
309377

310378
// Loop periodically sequences all entries in the pool and sends notifications to the waiting RPCs.
311379
//
380+
// Must be called after Preflight() returns success.
381+
//
312382
// At process shutdown, this context should be canceled _after_ GracefulStop returns. That ensures
313383
// there are no inflight RPCs from clients, which in turn ensures that we have sequenced everything
314384
// had in the pool.
@@ -385,7 +455,13 @@ func (m *mtca) fakePublisher(ctx context.Context) {
385455
//
386456
// Each entry in the pool will get a notification on its channel: either the index at which
387457
// it was sequenced, or -1 if there was an error during sequencing.
458+
//
459+
// Must only be called after Preflight() returns success.
388460
func (m *mtca) sequence(ctx context.Context) error {
461+
if m.frontier == nil {
462+
return fmt.Errorf("call mtca.Preflight() before sequencing")
463+
}
464+
389465
if m.pool.len() == 0 {
390466
return nil
391467
}
@@ -414,26 +490,44 @@ func (m *mtca) sequence(ctx context.Context) error {
414490
}
415491
}()
416492

417-
// Simulate writing to tile storage
418-
latestTreeSize := latest.TreeSize
419-
var entryIndexes []int64
493+
candidate := m.frontier.Clone()
494+
495+
// Add leaves to the candidate.
420496
for _, e := range entries {
421-
m.log.AuditInfo("Preparing to issue: %x", e)
422-
entryIndexes = append(entryIndexes, latestTreeSize)
423-
latestTreeSize++
497+
err = candidate.AppendEntry(e.mtcle)
498+
if err != nil {
499+
return err
500+
}
424501
}
425502

426-
// TODO: calculate new root hash for real
427-
var newRootHash [sha256.Size]byte
428-
rand.Read(newRootHash[:])
503+
newRootHash := candidate.RootHash()
504+
505+
// Log each leaf along with the root hash it will be included in.
506+
for i, e := range entries {
507+
m.log.AuditInfo("issuing", map[string]any{
508+
"TBSCertificateLogEntry": hex.EncodeToString(e.mtcle.TBS()),
509+
"entryIndex": latest.TreeSize + int64(i),
510+
"mtcLogID": m.mtcLogID(),
511+
"newRootHash": newRootHash.String(),
512+
})
513+
}
514+
515+
// First stage to a pending area. After signing and storing to the DB
516+
// (but before publishing a new checkpoint signed note), we will flush
517+
// to the live location. This ensures we've persisted the tiles before
518+
// committing to a tree hash by signing it.
519+
err = candidate.Stage(ctx, m.s3c, m.tileStoragePrefix())
520+
if err != nil {
521+
return fmt.Errorf("staging candidate tiles: %s", err)
522+
}
429523

430524
newCheckpoint := checkpoint{
431525
ID: 0,
432526
MTCLogID: m.mtcLogID(),
433527
MTCASignature: nil,
434528
MirrorID: "",
435529
MirrorSignature: nil,
436-
TreeSize: latestTreeSize,
530+
TreeSize: candidate.TreeSize(),
437531
RootHash: newRootHash[:],
438532
}
439533

@@ -510,9 +604,24 @@ func (m *mtca) sequence(ctx context.Context) error {
510604
return err
511605
}
512606

607+
m.frontier = candidate
608+
609+
// Write the tiles to a live serving location.
610+
//
611+
// TODO(#8902): This should include indefinite retries on error. We've committed to the
612+
// tree hash by signing it, so nothing can make progress until we've published the tiles.
613+
//
614+
// Once we add publishing of checkpoints as signed notes, publication of the signed note
615+
// should come after this flush succeeds, so monitors don't try to fetch tiles that aren't
616+
// yet available.
617+
err = m.frontier.Publish(ctx, m.s3c, m.tileStoragePrefix())
618+
if err != nil {
619+
return fmt.Errorf("publishing tiles: %s", err)
620+
}
621+
513622
// Notify waiting RPCs.
514623
for i, e := range entries {
515-
e.ch <- entryIndexes[i]
624+
e.ch <- latest.TreeSize + int64(i)
516625
}
517626
// Empty out the entries list so the deferred error path doesn't try to notify them.
518627
entries = nil

0 commit comments

Comments
 (0)