Skip to content

Commit 62e0d92

Browse files
committed
Harden tickets
1 parent 0d2d8a7 commit 62e0d92

4 files changed

Lines changed: 143 additions & 101 deletions

File tree

cmd/mtc/mirror/internal/handler/handler.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,14 @@ func addEntries(m *MirrorMux) http.HandlerFunc {
7676
}()
7777
default:
7878
// Log unknown encodings and treat as malformed request
79-
slog.WarnContext(r.Context(), "Unknown Content-Encoding", slog.String("encoding", ce))
79+
slog.DebugContext(r.Context(), "Unknown Content-Encoding", slog.String("encoding", ce))
8080
http.Error(w, "Unsupported content encoding", http.StatusBadRequest)
8181
return
8282
}
8383
req, err := parseAddEntriesPreamble(reader)
8484
if err != nil {
85-
http.Error(w, "Failed to parse request preamble", http.StatusBadRequest)
85+
slog.DebugContext(r.Context(), "Failed to parse request preamble", slog.Any("err", err))
86+
http.Error(w, "Invalid request preamble", http.StatusBadRequest)
8687
return
8788
}
8889

@@ -108,7 +109,7 @@ func addEntries(m *MirrorMux) http.HandlerFunc {
108109
return
109110

110111
case err != nil:
111-
slog.ErrorContext(r.Context(), "Failed to add entries", slog.String("origin", req.logOrigin), slog.Any("err", err))
112+
slog.DebugContext(r.Context(), "Failed to add entries", slog.String("origin", req.logOrigin), slog.Any("err", err))
112113
http.Error(w, "Failed to add entries", http.StatusInternalServerError)
113114
return
114115

cmd/mtc/mirror/posix/main.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ var (
4747
witnessCosignerPath = flag.String("witness_cosigner_path", "", "The path to the note-formatted witness cosigner secret key.")
4848
mirrorCosignerPath = flag.String("mirror_cosigner_path", "", "The path to the note-formatted mirror cosigner secret key.")
4949
mirrorConfigPath = flag.String("config_path", "", "The path to the mirror configuration file.")
50+
slogLevel = flag.Int("slog_level", 0, "The cut-off threshold for structured logging.")
5051
)
5152

5253
func main() {
5354
flag.Parse()
54-
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
55+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.Level(*slogLevel)})))
5556
ctx := context.Background()
5657

5758
w, wp, shutdown := witnessFromFlags(ctx)
@@ -75,7 +76,7 @@ func main() {
7576
origin := v.Name()
7677

7778
// Create the mirror
78-
t, err := newMirrorTarget(ctx, w, origin, mirrorCosigner)
79+
t, err := newMirrorTarget(ctx, w, v, mirrorCosigner)
7980
if err != nil {
8081
slog.ErrorContext(ctx, "Failed to create mirror target", slog.String("origin", origin), slog.Any("error", err))
8182
os.Exit(1)
@@ -107,7 +108,9 @@ func main() {
107108
//
108109
// The target directory for the driver is derived from the storage directory and the origin in accordance
109110
// with the `tlog-mirror` spec, allowing the root of the storage directory to be exported directly to read-only clients.
110-
func newMirrorTarget(ctx context.Context, w *witness.Witness, origin string, mirrorSigner note.Signer) (*tessera.MirrorTarget, error) {
111+
func newMirrorTarget(ctx context.Context, w *witness.Witness, logVerifier note.Verifier, mirrorSigner note.Signer) (*tessera.MirrorTarget, error) {
112+
origin := logVerifier.Name()
113+
111114
targetDir := filepath.Join(*storageDir, url.PathEscape(origin))
112115
if err := os.MkdirAll(targetDir, 0o755); err != nil {
113116
return nil, fmt.Errorf("mkdir %q: %v", targetDir, err)
@@ -120,6 +123,7 @@ func newMirrorTarget(ctx context.Context, w *witness.Witness, origin string, mir
120123
WithCheckpointSource(func(ctx context.Context) ([]byte, error) {
121124
return w.GetCheckpoint(ctx, origin)
122125
}).
126+
WithLogVerifier(logVerifier).
123127
WithSigner(mirrorSigner)
124128
return tessera.NewMirrorTarget(ctx, d, mOpts)
125129
}

mirror_lifecycle.go

Lines changed: 85 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ package tessera
1717
import (
1818
"bytes"
1919
"context"
20-
"encoding/gob"
20+
"crypto/hkdf"
21+
"crypto/hmac"
22+
"crypto/rand"
23+
"crypto/sha256"
2124
"encoding/hex"
2225
"errors"
2326
"fmt"
@@ -42,15 +45,22 @@ var (
4245

4346
// MirrorOptions holds mirror lifecycle settings for all storage implementations.
4447
type MirrorOptions struct {
45-
signer note.Signer
46-
cpSource func(context.Context) ([]byte, error)
48+
signer note.Signer
49+
cpSource func(context.Context) ([]byte, error)
50+
logVerifier note.Verifier
4751
}
4852

4953
// NewMirrorOptions creates a new options struct with defaults.
5054
func NewMirrorOptions() *MirrorOptions {
5155
return &MirrorOptions{}
5256
}
5357

58+
// WithLogVerifier sets the note.Verifier used to verify log checkpoint signatures.
59+
func (o *MirrorOptions) WithLogVerifier(v note.Verifier) *MirrorOptions {
60+
o.logVerifier = v
61+
return o
62+
}
63+
5464
// WithSigner configures the note.Signer to use when cosigning checkpoints.
5565
func (o *MirrorOptions) WithSigner(s note.Signer) *MirrorOptions {
5666
o.signer = s
@@ -76,6 +86,9 @@ func (o *MirrorOptions) LeafHasher() func(bundle []byte) (leafHashes [][]byte, e
7686
}
7787

7888
func (o *MirrorOptions) valid() error {
89+
if o.logVerifier == nil {
90+
return errors.New("invalid MirrorOptions: WithLogVerifier must be set")
91+
}
7992
if o.signer == nil {
8093
return errors.New("invalid MirrorOptions: WithSigner must be set")
8194
}
@@ -97,10 +110,11 @@ type MirrorWriter interface {
97110
// MirrorTarget is a high-level wrapper that manages the process of mirroring
98111
// a source log into a Tessera instance.
99112
type MirrorTarget struct {
100-
writer MirrorWriter
101-
reader LogReader
102-
cpSource func(context.Context) ([]byte, error)
103-
signer note.Signer
113+
writer MirrorWriter
114+
reader LogReader
115+
cpSource func(context.Context) ([]byte, error)
116+
signer note.Signer
117+
ticketKey []byte
104118
}
105119

106120
// NewMirrorTarget instantiates a new MirrorTarget for the given driver and options.
@@ -122,14 +136,41 @@ func NewMirrorTarget(ctx context.Context, d Driver, opts *MirrorOptions) (*Mirro
122136
if err != nil {
123137
return nil, fmt.Errorf("failed to init MirrorTarget lifecycle: %v", err)
124138
}
139+
tK, err := ticketKey(opts.signer.Name(), opts.logVerifier.Name())
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to derive ticket key: %v", err)
142+
}
125143
return &MirrorTarget{
126-
writer: mw,
127-
reader: r,
128-
cpSource: opts.cpSource,
129-
signer: opts.signer,
144+
writer: mw,
145+
reader: r,
146+
cpSource: opts.cpSource,
147+
signer: opts.signer,
148+
ticketKey: tK,
130149
}, nil
131150
}
132151

152+
// ticketKey derives a unique HMAC key for sealing tickets based on:
153+
// - An ephemeral seed,
154+
// - Identity (origin) of the mirror cosigner,
155+
// - Identity (origin) of the log being mirrored.
156+
//
157+
// It should be called, once, at startup to set the ticket MAC key for the mirror.
158+
//
159+
// TODO(al): We should allow the operator to pass in the seed, so that tickets
160+
// will work across multiple mirror instances and/or restarts.
161+
func ticketKey(mirrorOrigin, logOrigin string) ([]byte, error) {
162+
seed := make([]byte, sha256.Size)
163+
if _, err := rand.Read(seed); err != nil {
164+
return nil, fmt.Errorf("failed to generate ephemeral seed: %v", err)
165+
}
166+
// This salt will keep the key unique per mirror, even if the random seed generation above
167+
// were changed to be a "fixed" value provided by the operator.
168+
salt := sha256.Sum256(fmt.Appendf(nil, "mirror:\n%s\n", mirrorOrigin))
169+
// Bind this key to its usage for MACing tickets for the given log.
170+
info := fmt.Sprintf("ticket-hmac\nlog:\n%s\n", logOrigin)
171+
return hkdf.Key(sha256.New, seed, salt[:], info, sha256.Size)
172+
}
173+
133174
// Package represents a single package of entries and its subtree consistency proof.
134175
type MirrorPackage struct {
135176
Entries [][]byte
@@ -147,42 +188,43 @@ func (mt *MirrorTarget) AddEntries(ctx context.Context, uploadStart, uploadEnd u
147188
if err != nil {
148189
return 0, 0, nil, nil, fmt.Errorf("failed to read integrated size: %w", err)
149190
}
150-
var t *ticket
151-
if t, err = mt.openTicket(ctx, ticketBytes); err != nil {
152-
// Invalid or empty ticket, return a new one.
191+
ticketCP, err := mt.openTicket(ticketBytes)
192+
if err != nil {
193+
slog.DebugContext(ctx, "Invalid Ticket received, returning new one", slog.Any("error", err), slog.Uint64("uploadStart", uploadStart), slog.Uint64("uploadEnd", uploadEnd))
194+
195+
// If the client didn't provide a [valid] ticket, then we don't have a pending
196+
// checkpoint to validate against, so we return a new ticket with the
197+
// current checkpoint.
153198
pendingCP, err := mt.cpSource(ctx)
154199
if err != nil {
155200
return 0, 0, nil, nil, fmt.Errorf("failed to get pending checkpoint: %v", err)
156201
}
157202
if len(pendingCP) == 0 {
158203
return 0, 0, nil, nil, ErrNoPendingCheckpoint
159204
}
160-
t = &ticket{
161-
PendingCP: pendingCP,
162-
}
163-
ticketBytes, err = mt.sealTicket(ctx, t)
205+
ticketBytes, err = mt.sealTicket(pendingCP)
164206
if err != nil {
165207
return 0, 0, nil, nil, fmt.Errorf("failed to create ticket: %v", err)
166208
}
167209

168-
// If the client didn't provide a [valid] ticket, then we don't have a pending
169-
// checkpoint to validate against, so we return a new ticket with the
170-
// current checkpoint.
171-
_, pendingSize, _, err := parse.CheckpointUnsafe(t.PendingCP)
210+
_, pendingSize, _, err := parse.CheckpointUnsafe(pendingCP)
172211
if err != nil {
173-
slog.ErrorContext(ctx, "Invalid pending checkpoint from source", slog.String("pending_checkpoint", string(t.PendingCP)), slog.String("error", err.Error()))
212+
slog.ErrorContext(ctx, "Invalid pending checkpoint from source", slog.String("pending_checkpoint", string(pendingCP)), slog.String("error", err.Error()))
174213
return 0, 0, nil, nil, fmt.Errorf("failed to parse pending checkpoint while creating ticket: %v", err)
175214
}
215+
216+
slog.DebugContext(ctx, "Returning new ticket", slog.Uint64("curIntegratedSize", curIntegratedSize), slog.Uint64("pendingSize", pendingSize))
176217
return curIntegratedSize, pendingSize, ticketBytes, nil, ErrConflict
177218
}
178219

179220
var pendingRoot []byte
180-
_, pendingSize, pendingRoot, err = parse.CheckpointUnsafe(t.PendingCP)
221+
_, pendingSize, pendingRoot, err = parse.CheckpointUnsafe(ticketCP)
181222
if err != nil {
182-
slog.ErrorContext(ctx, "Invalid pending checkpoint in ticket", slog.String("pending_checkpoint", string(t.PendingCP)), slog.String("error", err.Error()))
223+
slog.ErrorContext(ctx, "Invalid pending checkpoint in ticket", slog.String("pending_checkpoint", string(ticketCP)), slog.String("error", err.Error()))
183224
return 0, 0, nil, nil, fmt.Errorf("failed to parse pending checkpoint from ticket: %v", err)
184225
}
185226

227+
slog.DebugContext(ctx, "Valid ticket, proceeding", slog.Uint64("curIntegratedSize", curIntegratedSize), slog.Uint64("pendingSize", pendingSize))
186228
// Handle 409 Conflicts:
187229
// - Zero-request check: If upload_start == 0 and upload_end == 0, the client is
188230
// requesting initial mirror information.
@@ -196,6 +238,7 @@ func (mt *MirrorTarget) AddEntries(ctx context.Context, uploadStart, uploadEnd u
196238
(uploadEnd != pendingSize || uploadEnd < curIntegratedSize) ||
197239
(uploadStart > curIntegratedSize) {
198240
// TODO(al): add flexibility about re-writing some entries
241+
slog.ErrorContext(ctx, "Returning conflict", slog.Uint64("curIntegratedSize", curIntegratedSize), slog.Uint64("pendingSize", pendingSize), slog.Uint64("uploadStart", uploadStart), slog.Uint64("uploadEnd", uploadEnd))
199242
return curIntegratedSize, pendingSize, ticketBytes, nil, ErrConflict
200243
}
201244

@@ -228,7 +271,7 @@ func (mt *MirrorTarget) AddEntries(ctx context.Context, uploadStart, uploadEnd u
228271
return 0, 0, nil, nil, err
229272
case nextEntry == pendingSize:
230273
if !bytes.Equal(pendingRoot, newRoot) {
231-
slog.ErrorContext(ctx, "CORRUPTION DETECTED - pending root != calculated root", slog.String("calculated_root", hex.EncodeToString(newRoot)), slog.String("pending_checkpoint", string(t.PendingCP)))
274+
slog.ErrorContext(ctx, "CORRUPTION DETECTED - pending root != calculated root", slog.String("calculated_root", hex.EncodeToString(newRoot)), slog.String("pending_checkpoint", string(ticketCP)))
232275
return 0, 0, nil, nil, errors.New("internal error")
233276
}
234277
// This is a complete upload.
@@ -238,8 +281,7 @@ func (mt *MirrorTarget) AddEntries(ctx context.Context, uploadStart, uploadEnd u
238281
// - If published, then return the cosig(s) to the caller.
239282
return nextEntry, pendingSize, nil, []byte("— test cosig\n"), nil
240283
case nextEntry > pendingSize:
241-
// TODO(al): ticket is stale, probably need to update the ticket?
242-
slog.WarnContext(ctx, "nextEntry > pendingSize", slog.Uint64("nextEntry", nextEntry), slog.Uint64("pendingSize", pendingSize))
284+
// Ticket is stale, update the ticket
243285
return nextEntry, pendingSize, ticketBytes, nil, nil
244286
default:
245287
// Incomplete upload, return an updated ticket with the current checkpoint.
@@ -252,29 +294,24 @@ func (mt *MirrorTarget) IntegratedSize(ctx context.Context) (uint64, error) {
252294
return mt.reader.IntegratedSize(ctx)
253295
}
254296

255-
// ticket is the underlying structure of an add-entries ticket.
256-
type ticket struct {
257-
// PendingCP holds the raw pending checkpoint bytes.
258-
PendingCP []byte
297+
func (mt *MirrorTarget) sealTicket(pendingCP []byte) ([]byte, error) {
298+
h := hmac.New(sha256.New, mt.ticketKey)
299+
h.Write(pendingCP)
300+
mac := h.Sum(nil)
301+
return append(mac, pendingCP...), nil
259302
}
260303

261-
func (mt *MirrorTarget) sealTicket(ctx context.Context, t *ticket) ([]byte, error) {
262-
out := bytes.Buffer{}
263-
if err := gob.NewEncoder(&out).Encode(t); err != nil {
264-
return nil, fmt.Errorf("ticket encoding failed: %v", err)
304+
func (mt *MirrorTarget) openTicket(ticketBytes []byte) ([]byte, error) {
305+
if len(ticketBytes) < sha256.Size {
306+
return nil, errors.New("invalid ticket")
265307
}
266-
// TODO(al): harden ticket & bind to this particular log mirror.
267-
return out.Bytes(), nil
268-
}
269308

270-
func (mt *MirrorTarget) openTicket(ctx context.Context, ticketBytes []byte) (*ticket, error) {
271-
if len(ticketBytes) == 0 {
272-
return nil, errors.New("empty ticket")
273-
}
274-
// TODO(al): harden ticket & verify it's for this particular log mirror.
275-
var t ticket
276-
if err := gob.NewDecoder(bytes.NewReader(ticketBytes)).Decode(&t); err != nil {
277-
return nil, fmt.Errorf("ticket decoding failed: %v", err)
309+
mac, pendingCP := ticketBytes[:sha256.Size], ticketBytes[sha256.Size:]
310+
311+
h := hmac.New(sha256.New, mt.ticketKey)
312+
h.Write(pendingCP)
313+
if !hmac.Equal(mac, h.Sum(nil)) {
314+
return nil, errors.New("invalid ticketMAC")
278315
}
279-
return &t, nil
316+
return pendingCP, nil
280317
}

0 commit comments

Comments
 (0)