Skip to content

Commit afb834f

Browse files
authored
Authenticate tickets (#1022)
Hardens the tickets used in the `tlog-mirror` lifecycle using an HMAC to authenticate them and protect against modification. Each {mirror, log} combination uses its own HMAC key, reducing the risk of confusion from tickets from other services or mirrored logs being presented. This binding is done using HKDF. Currently, we create an ephemeral key seed at each restart. This is fine for now (clients will just need to refresh tickets if the service restarts), but we will need to add support for a configurable fixed seed if we want to support running multiple frontends for non-POSIX storage implementations.
1 parent f54442c commit afb834f

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)