Skip to content

Commit 74446b0

Browse files
authored
[MTC] Add first cut of mirror HTTP handler (transparency-dev#946)
Adds a very rough sketch for an MTC tlog-mirror API server, primarily intended to enable work on the lower storage layers, subtree proofs, and test client to proceed.
1 parent 5aa29ef commit 74446b0

6 files changed

Lines changed: 550 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Tessera is generally available and production ready. The following items are pla
9090
| 10 | Mirrored logs ([#576][]) | ⚠️ |
9191
| 11 | Preordered logs ([#575][]) ||
9292
| 12 | Trillian v1 to Tessera migration ([#577][]) ||
93+
| 13 | Merkle Tree Certificates support ([#945][]) | ⚠️ |
9394
| N | Fancy features (to be expanded upon later) ||
9495

9596
### What’s happening to Trillian v1?
@@ -451,3 +452,4 @@ transparency ecosystems over the years.
451452
[#575]: https://github.com/transparency-dev/tessera/issues/575
452453
[#576]: https://github.com/transparency-dev/tessera/issues/576
453454
[#577]: https://github.com/transparency-dev/tessera/issues/577
455+
[#945]: https://github.com/transparency-dev/tessera/issues/945

cmd/mtc/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# MTC
2+
3+
This directory contains some experimental/WIP binaries which implement the
4+
[MTC](https://datatracker.ietf.org/doc/html/draft-ietf-plants-merkle-tree-certs) and
5+
[tlog-mirror](https://github.com/C2SP/C2SP/blob/main/tlog-mirror.md) specs.
6+
7+
Everything under this directory, along with any other in-progress MTC-related support elsewhere in this repo, may be subject to large and unannounced
8+
changes and so should be considered excluded from the SemVer policy for the time being.
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Copyright 2026 The Tessera authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package handler
16+
17+
import (
18+
"bufio"
19+
"context"
20+
"encoding/base64"
21+
"encoding/binary"
22+
"errors"
23+
"fmt"
24+
"io"
25+
"net/http"
26+
"strconv"
27+
"strings"
28+
29+
"log/slog"
30+
31+
"github.com/transparency-dev/tessera/cmd/mtc/mirror/internal/mirror"
32+
"github.com/transparency-dev/tessera/internal/parse"
33+
)
34+
35+
const (
36+
// maxOriginLen is the maximum length of a valid log origin.
37+
// This value comes from the MLDSA cosigner spec.
38+
maxOriginLen = 255
39+
// maxTicketSize is the maximum length of a tlog-mirror ticket.
40+
maxTicketSize = 1<<16 - 1
41+
)
42+
43+
// Mirror is the interface that the handler uses to interact with the mirror's state.
44+
type Mirror interface {
45+
AddCheckpoint(ctx context.Context, oldSize uint64, proof [][]byte, cp []byte) error
46+
AddEntries(ctx context.Context, logOrigin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*mirror.Package, error)) ([]byte, error)
47+
}
48+
49+
// New returns a new http.Handler for the mirror service.
50+
func New(m Mirror) http.Handler {
51+
mux := http.NewServeMux()
52+
mux.HandleFunc("POST /add-checkpoint", addCheckpoint(m))
53+
mux.HandleFunc("POST /add-entries", addEntries(m))
54+
return mux
55+
}
56+
57+
func addCheckpoint(m Mirror) http.HandlerFunc {
58+
return func(w http.ResponseWriter, r *http.Request) {
59+
// SPEC: The mirror implements a [tlog-]witness's add-checkpoint endpoint.
60+
// MUST be a sequence of:
61+
// - an old size line,
62+
// - zero or more consistency proof lines,
63+
// - and an empty line,
64+
// - followed by a checkpoint.
65+
66+
reader := bufio.NewReader(r.Body)
67+
68+
// 1. Read old size line.
69+
oldLine, err := reader.ReadString('\n')
70+
if err != nil {
71+
http.Error(w, "missing old size", http.StatusBadRequest)
72+
return
73+
}
74+
oldLine = strings.TrimSpace(oldLine)
75+
if !strings.HasPrefix(oldLine, "old ") {
76+
http.Error(w, "invalid old size line", http.StatusBadRequest)
77+
return
78+
}
79+
oldSize, err := strconv.ParseUint(strings.TrimPrefix(oldLine, "old "), 10, 64)
80+
if err != nil {
81+
http.Error(w, "invalid old size", http.StatusBadRequest)
82+
return
83+
}
84+
85+
// 2. Read consistency proof lines until an empty line.
86+
var proof [][]byte
87+
for {
88+
line, err := reader.ReadString('\n')
89+
if err != nil {
90+
http.Error(w, "unexpected EOF while reading proof", http.StatusBadRequest)
91+
return
92+
}
93+
line = strings.TrimSpace(line)
94+
if line == "" {
95+
break
96+
}
97+
p, err := base64.StdEncoding.DecodeString(line)
98+
if err != nil {
99+
http.Error(w, "invalid proof line", http.StatusBadRequest)
100+
return
101+
}
102+
proof = append(proof, p)
103+
}
104+
105+
// 3. Remaining data is the checkpoint.
106+
cp, err := io.ReadAll(reader)
107+
if err != nil {
108+
http.Error(w, "failed to read checkpoint", http.StatusBadRequest)
109+
return
110+
}
111+
112+
if _, _, _, err := parse.CheckpointUnsafe(cp); err != nil {
113+
http.Error(w, fmt.Sprintf("invalid checkpoint: %v", err), http.StatusBadRequest)
114+
return
115+
}
116+
117+
if err := m.AddCheckpoint(r.Context(), oldSize, proof, cp); err != nil {
118+
slog.ErrorContext(r.Context(), "AddCheckpoint failed", slog.Any("error", err))
119+
http.Error(w, err.Error(), http.StatusInternalServerError)
120+
return
121+
}
122+
123+
w.WriteHeader(http.StatusOK)
124+
// TODO(al): Maybe return a tlog-witness only cosignature here from a separate key?
125+
}
126+
}
127+
128+
func addEntries(m Mirror) http.HandlerFunc {
129+
return func(w http.ResponseWriter, r *http.Request) {
130+
// SPEC: The request body MUST have Content-Type of application/octet-stream ...
131+
if t := strings.ToLower(r.Header.Get("Content-Type")); t != "application/octet-stream" {
132+
http.Error(w, fmt.Sprintf("invalid Content-Type %q", t), http.StatusBadRequest)
133+
return
134+
}
135+
req, err := parseAddEntriesPreamble(r.Body)
136+
if err != nil {
137+
http.Error(w, fmt.Sprintf("failed to parse header: %v", err), http.StatusBadRequest)
138+
return
139+
}
140+
141+
cosigs, err := m.AddEntries(r.Context(), req.logOrigin, req.uploadStart, req.uploadEnd, req.ticket, req.NextPackage)
142+
if err != nil {
143+
// TODO(al): Handle Conflict (409) if it's a conflict error.
144+
slog.ErrorContext(r.Context(), "AddEntries failed", slog.Any("error", err))
145+
http.Error(w, err.Error(), http.StatusInternalServerError)
146+
return
147+
}
148+
149+
w.Header().Set("Content-Type", "text/plain")
150+
_, _ = w.Write(cosigs)
151+
}
152+
}
153+
154+
// addEntriesRequest represents the body of a request to the tlog-mirror add-entries endpoint.
155+
type addEntriesRequest struct {
156+
logOrigin string
157+
uploadStart uint64
158+
uploadEnd uint64
159+
ticket []byte
160+
body io.Reader
161+
162+
// start is the index of the next entry in the stream of entry packages.
163+
// Initially it will be equal to uploadStart, and will be moved forwards
164+
// by calls to NextPackage().
165+
start uint64
166+
}
167+
168+
// NextPackage returns the next entry package in the request, if any.
169+
//
170+
// Once all packages from the request have been consumed, this func will continue
171+
// to return io.EOF.
172+
//
173+
// Not thread-safe.
174+
func (a *addEntriesRequest) NextPackage() (*mirror.Package, error) {
175+
if a.start >= a.uploadEnd {
176+
return nil, io.EOF
177+
}
178+
179+
// Calculate how many entries in this package
180+
end := min(a.uploadEnd, (a.start/256+1)*256)
181+
numEntries := int(end - a.start)
182+
183+
// Now parse the package.
184+
//
185+
// SPEC: The package MUST contain the following values, concatenated.
186+
// - The log entries in [start, end), each with a big-endian uint16 length prefix,
187+
// - 1 byte, encoding an 8-bit unsigned integer, num_hashes, which MUST be at most 63,
188+
// - num_hashes subtree consistency proof hash values.
189+
190+
// First the entries themselves.
191+
var entries [][]byte
192+
for range numEntries {
193+
var entryLen uint16
194+
if err := binary.Read(a.body, binary.BigEndian, &entryLen); err != nil {
195+
return nil, fmt.Errorf("failed to read entry length: %v", err)
196+
}
197+
entry := make([]byte, entryLen)
198+
if _, err := io.ReadFull(a.body, entry); err != nil {
199+
return nil, fmt.Errorf("failed to read entry: %v", err)
200+
}
201+
entries = append(entries, entry)
202+
}
203+
204+
// Now the proof length.
205+
var numHashes uint8
206+
if err := binary.Read(a.body, binary.BigEndian, &numHashes); err != nil {
207+
return nil, fmt.Errorf("failed to read num_hashes: %v", err)
208+
}
209+
if numHashes > 63 {
210+
return nil, fmt.Errorf("too many hashes: %d", numHashes)
211+
}
212+
213+
// Finally, the proof itself.
214+
var proofs [][]byte
215+
for i := 0; i < int(numHashes); i++ {
216+
hash := make([]byte, 32) // Proof is comprised of SHA256 hashes.
217+
if _, err := io.ReadFull(a.body, hash); err != nil {
218+
return nil, fmt.Errorf("failed to read hash: %v", err)
219+
}
220+
proofs = append(proofs, hash)
221+
}
222+
223+
// Update next expected entry index.
224+
a.start = end
225+
return &mirror.Package{Entries: entries, Proof: proofs}, nil
226+
}
227+
228+
// parseAddEntriesPreamble consumes the body of the Add-Entries request.
229+
//
230+
// This func effectively takes ownership of the provided Reader, it MUST NOT be
231+
// further used by the caller.
232+
//
233+
// Returns a struct which represents the request, and provides streaming access to the entry packages.
234+
func parseAddEntriesPreamble(r io.Reader) (*addEntriesRequest, error) {
235+
// SPEC: The request body MUST ... contain the following values, concatenated:
236+
// 2 bytes, encoding a big-endian uint16: log_origin_size
237+
// log_origin_size bytes, containing the log origin: log_origin
238+
// 8 bytes, encoding a big-endian uint64: upload_start
239+
// 8 bytes, encoding a big-endian uint64: upload_end
240+
// 2 bytes, encoding a big-endian uint16: ticket_size
241+
// ticket_size bytes, containing an opaque ticket value, described below
242+
// A sequence of entry packages
243+
var logOriginSize uint16
244+
if err := binary.Read(r, binary.BigEndian, &logOriginSize); err != nil {
245+
return nil, err
246+
}
247+
if logOriginSize > maxOriginLen {
248+
return nil, errors.New("log origin too long")
249+
}
250+
logOrigin := make([]byte, logOriginSize)
251+
if _, err := io.ReadFull(r, logOrigin); err != nil {
252+
return nil, err
253+
}
254+
255+
var uploadStart uint64
256+
if err := binary.Read(r, binary.BigEndian, &uploadStart); err != nil {
257+
return nil, err
258+
}
259+
260+
var uploadEnd uint64
261+
if err := binary.Read(r, binary.BigEndian, &uploadEnd); err != nil {
262+
return nil, err
263+
}
264+
265+
var ticketSize uint16
266+
if err := binary.Read(r, binary.BigEndian, &ticketSize); err != nil {
267+
return nil, err
268+
}
269+
if ticketSize > maxTicketSize {
270+
return nil, errors.New("ticket too large")
271+
}
272+
var ticket []byte
273+
if ticketSize > 0 {
274+
ticket = make([]byte, ticketSize)
275+
if _, err := io.ReadFull(r, ticket); err != nil {
276+
return nil, err
277+
}
278+
}
279+
280+
// SPEC: upload_start MUST be less or equal to upload_end
281+
if uploadStart > uploadEnd {
282+
return nil, fmt.Errorf("uploadStart (%d) > uploadEnd (%d)", uploadStart, uploadEnd)
283+
}
284+
285+
return &addEntriesRequest{
286+
logOrigin: string(logOrigin),
287+
uploadStart: uploadStart,
288+
uploadEnd: uploadEnd,
289+
ticket: ticket,
290+
body: r,
291+
start: uploadStart,
292+
}, nil
293+
}

0 commit comments

Comments
 (0)