Add tlog_mirror crate with wire format and default ticket authenticator#236
Add tlog_mirror crate with wire format and default ticket authenticator#236lukevalenta wants to merge 1 commit into
Conversation
|
/bonk review |
There was a problem hiding this comment.
Did a careful review against the c2sp.org/tlog-mirror spec and ran the pre-push checks. Wire-format math, ticket scheme, and test coverage are solid. A few things to address before this lands:
Must-fix
1. Stale TicketSealer references break cargo doc (error.rs, wire.rs)
Three intra-doc links to crate::TicketSealer survive from an earlier AEAD-based design — the type is now TicketMacer. Confirmed locally:
$ RUSTDOCFLAGS=\"-D rustdoc::broken_intra_doc_links\" cargo doc -p tlog_mirror --no-deps
error: unresolved link to \`crate::TicketSealer\`
--> crates/tlog_mirror/src/wire.rs:64:26
error: unresolved link to \`crate::TicketSealer\`
--> crates/tlog_mirror/src/error.rs:62:41
Locations:
crates/tlog_mirror/src/wire.rs:64— ``TicketSealer``crates/tlog_mirror/src/error.rs:62— `Errors returned by `TicketSealer`.`
While you're in error.rs, the TicketError variant docs are also AEAD-flavored leftovers that no longer match the implementation:
- Line 65: "sealed ticket too short: ... 12-byte nonce" — there is no nonce; the threshold is
TAG_LEN = 16. - Line 70: "AEAD authentication failed: either the ciphertext or the nonce has been tampered with" — no AEAD, no nonce, no ciphertext.
Suggest rewording to refer to TicketMacer, TAG_LEN, and HMAC tag verification.
2. cargo package -p tlog_mirror fails — missing README.md
crates/tlog_mirror/Cargo.toml:3 hard-codes readme = \"README.md\", but no README.md exists in the crate directory. Sibling crates (tlog_witness, tlog_cosignature, static_ct_api) use readme.workspace = true, which resolves to the workspace root README during packaging.
$ cargo package -p tlog_mirror --no-verify --allow-dirty --list
error: readme \`README.md\` does not appear to exist (relative to \`.../crates/tlog_mirror\`).
This will also block any future cargo publish. Easiest fix: change to readme.workspace = true, matching siblings.
3. Integer overflow in package_ranges iterator (wire.rs:185)
let next_boundary = (start / PACKAGE_ALIGNMENT + 1) * PACKAGE_ALIGNMENT;This overflows u64 whenever start / 256 == u64::MAX / 256, i.e. roughly start >= 0xFFFF_FFFF_FFFF_FF00. In debug builds this panics with attempt to multiply with overflow; in release it wraps to 0, which makes end = next_boundary.min(upload_end) = 0, and since self.next = end = 0, the iterator silently restarts and yields wrong ranges.
Reachable from network input because AddEntriesRequestHeader::read_from accepts any upload_end up to u64::MAX. No real log will ever hit 2^64 entries, but a malicious or buggy peer absolutely can put any 8-byte value on the wire.
Suggested fix — saturate at upload_end:
let next_boundary = (start / PACKAGE_ALIGNMENT)
.checked_add(1)
.and_then(|q| q.checked_mul(PACKAGE_ALIGNMENT))
.unwrap_or(self.upload_end);
let end = next_boundary.min(self.upload_end);Add a regression test, e.g. package_ranges(u64::MAX - 1, u64::MAX) should yield (u64::MAX - 1, u64::MAX) and then terminate.
Should-fix
4. PR description claims publish = false but the manifest doesn't have it
The PR description says the crate is publish = false while the spec stabilizes, but crates/tlog_mirror/Cargo.toml has only [package.metadata.release] release = false — that's a cargo-release automation marker, not a cargo publish gate. (static_ct_api, which is explicitly published per AGENTS.md, has the same metadata.release block.) Either add publish = false under [package] or amend the PR description so it doesn't promise a guard that isn't there. Worth being explicit since the AGENTS.md "crates.io" boundary is an approval gate.
Nits / discussion
5. parse_decimal_u64 is stricter than the spec text
The spec says "in decimal" without explicitly forbidding leading zeros / + / whitespace. The current parser rejects all of those, which is fine for serializing — we only want one canonical form — but slightly aggressive when parsing a body produced by another mirror implementation. The inline comment already justifies this ("we want a single canonical encoding for each integer"), so I'm fine leaving it as-is, but worth flagging in case there's appetite for a Postel split (lenient parser, strict serializer).
6. Test gap (low priority)
EntryPackage::read_from converts num_entries: u64 to usize and only fails on 32-bit targets. Not realistically reachable from a tlog mirror, but a #[cfg(target_pointer_width = \"32\")] test is essentially free if you want to lock the behavior in.
Pre-push checks I ran locally on the head commit:
|
Review submitted. Summary of what I posted on PR #236: Must-fix (3):
Should-fix (1): PR description says Discussion/nits (2): strict decimal parser is more strict than spec text (currently inline-justified; flagged for awareness); 32-bit target test gap (low priority). Locally clippy/test/fmt all pass; only |
e68442a to
789acbb
Compare
|
/bonk review again |
|
@lukevalenta Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
There was a problem hiding this comment.
Re-reviewed against the previous review's findings and against the spec. All three must-fix items and the should-fix item are addressed; pre-push checks (including the two that previously failed) all pass locally on the head commit.
Previous review items — verified fixed
1. Stale TicketSealer references / AEAD verbiage ✅
wire.rs:64now points tocrate::TicketMacer.error.rs:62rewords to[\TicketMacer`](crate::TicketMacer)`.error.rs:65–75rewords the variants to refer toTAG_LEN(16 bytes) and HMAC tag verification — no more "12-byte nonce" / "AEAD authentication failed".RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc -p tlog_mirror --no-depsnow passes.
The only remaining mention of "AEAD" in the crate is in ticket.rs:34, in the doc paragraph that explicitly invites operators to roll their own AEAD-based scheme — that's intentional, not a leftover.
2. Missing README breaks cargo package ✅
crates/tlog_mirror/Cargo.toml:10now usesreadme.workspace = true, matching siblings.cargo package -p tlog_mirror --no-verify --allow-dirty --listsucceeds and includesREADME.md(resolved from the workspace root).
3. Integer overflow in package_ranges ✅
wire.rs:189–192now useschecked_add(1).and_then(|q| q.checked_mul(PACKAGE_ALIGNMENT)).unwrap_or(self.upload_end), with an inline comment explaining the saturation.- New regression test
package_ranges_no_overflow_at_u64_max(wire.rs:540–553) covers bothu64::MAX - 1, u64::MAXandedge, u64::MAX(whereedge = u64::MAX - (u64::MAX % 256)). Both yield a single correct range and terminate. - Total tests: 35 (up from 34).
4. publish = false not in manifest ✅
crates/tlog_mirror/Cargo.toml:5now setspublish = falsewith a comment explaining it'll be lifted once the spec stabilizes.[package.metadata.release] release = falseis also retained for cargo-release.
Pre-push checks (head commit 789acbb)
cargo clippy --workspace --all-targets -- -Dwarnings -Dclippy::pedantic ✅
cargo test ✅ (full workspace green; tlog_mirror 35/35)
cargo fmt --all --check ✅
RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc -p tlog_mirror --no-deps ✅
cargo package -p tlog_mirror --no-verify --allow-dirty --list ✅
(cargo machete not installed in this sandbox; CI runs it.)
Tiny nit (non-blocking)
The PR body still says "34 unit tests covering …". With the overflow regression test added, it's 35. Worth a one-character tweak before merge if you're amending the description; otherwise no action needed.
LGTM — approving.
|
Re-review submitted as APPROVED on PR #236. Summary of what I posted: All four previously-flagged items are fixed on head commit
All five pre-push checks (clippy, test, fmt, doc, package) pass locally; tests went from 34 to 35. Only nit: PR description still says "34 unit tests"; it's 35 now. Non-blocking. Approved. |
Implements the wire-format pieces of c2sp.org/tlog-mirror:
- `AddEntriesRequestHeader`: read/write framing for the add-entries
request body header (u16 log_origin, u64 upload_start/end, u16
ticket).
- `EntryPackage`: read/write framing for a single entry package
(u16-prefixed entries + u8 num_hashes <= 63 + 32-byte hashes).
- `package_ranges`: deterministic iterator over the spec's
256-aligned `[start, end)` intervals derived from upload_start
and upload_end. Callers iterate this rather than recomputing the
rounding math.
- `MirrorInfo`: read/write framing for the `text/x.tlog.mirror-info`
409 Conflict response body (3 newline-terminated lines: tree size,
next entry, base64-encoded ticket).
- `TicketMacer`: default opaque-ticket authenticator using
HMAC-SHA-256 truncated to 128 bits. Layout is `tag_16 || plaintext`.
Confidentiality is intentionally not provided: pending checkpoints
are public data, so the spec's MUST ("authenticate any information
derived from a ticket") only requires authentication. The
construction is deterministic; identical pending-checkpoint
plaintexts yield identical tickets, which makes the ticket a
content-addressable handle. 128-bit truncation matches AES-GCM tag
strength and is endorsed by NIST SP 800-107 and used by
HMAC-SHA256-128 TLS cipher suites and IPsec ESP. Mirror operators
who want a different payload type, AAD binding, or an AEAD
construction MAY ignore this module; the wire format treats the
ticket as opaque bytes.
The mirror's `add-checkpoint` endpoint reuses
`tlog_witness::add_checkpoint` per spec, so this crate does not
duplicate it.
34 unit tests cover wire-format roundtrips, malformation rejections,
package-range boundary math, ticket determinism, and ticket
tamper/wrong-key/short-input detection.
Adds `hmac = "0.13"` to workspace dependencies, paired with the
existing `sha2 = "0.11"`.
789acbb to
d2978b2
Compare
Summary
Introduces the
tlog_mirrorcrate, implementing the wire-format pieces of c2sp.org/tlog-mirror: theadd-entriesrequest body, thetext/x.tlog.mirror-info409 Conflict response body, and a default opaque-ticket authenticator.Towards #186 (tlog-mirror support); see the C1–C5 plan comment for how this fits into the broader mirror work. The crate is
publish = falsefor now while the spec stabilizes.What's in this PR
Wire format (
wiremodule)AddEntriesRequestHeader: read/write framing for theadd-entriesrequest body header — u16log_origin, u64upload_start/upload_end, u16ticket. Strict UTF-8 validation on origin, range-inversion rejection onupload_start > upload_end.EntryPackage: read/write framing for one entry package — u16-length-prefixed entries followed byu8 num_hashes(max 63 per spec) andnum_hashes * 32bytes of subtree consistency proof.package_ranges(upload_start, upload_end): deterministic iterator over the[start, end)package boundaries derived from the spec's 256-multiple alignment math. Callers iterate this rather than recomputing the rounding.MirrorInfo: read/write framing for thetext/x.tlog.mirror-info409 response body — three\n-terminated lines (tree size in decimal, next entry in decimal, base64-encoded ticket). Strict decimal parser rejects leading zeros, leading+, negatives, whitespace, and non-ASCII-decimal bytes.The header is parsed via
Read/Writestreams (the spec mandates streaming foradd-entries); inside each package, sizes are bounded so per-package buffering is fine.Default ticket authenticator (
ticketmodule)TicketMaceruses HMAC-SHA-256 truncated to 128 bits. Ticket layout istag_16 || plaintext, with constant-time tag verification onopen(viaMac::verify_truncated_left).Why authentication, not encryption?
The spec only requires that the mirror authenticate any information derived from a ticket. Pending-checkpoint plaintext is public data — operators publish it at
<monitoring prefix>/<encoded origin>/checkpoint. There's nothing to hide; confidentiality buys us nothing.Why HMAC-SHA-256-128?
HMAC-SHA256-128TLS cipher suites, IPsec ESPAUTH_HMAC_SHA2_256_128.hmac = "0.13.0", paired with our existingsha2 = "0.11") — no rc-pinning, no transitive pre-release ecosystem.aes-gcm-siv, noaead/cipher/ctr/polyval/aestransitive pulls)./checkpoint.Operators who want a different scheme (AEAD, AAD-bound, structured payload, etc.) can swap the entire ticket construction; the wire format treats the ticket as opaque bytes.
Out of scope (deliberately)
add-checkpointendpoint: the spec says mirrors handle this identically to a witness, so operators reusetlog_witness::add_checkpointdirectly. This crate does not duplicate it.EntryPackagebut verification is atlog_tiles/tlog_coremath concern that lives elsewhere. (See Split tlog_tiles crate so it implements only c2sp.org/tlog-tiles #230 for the crate-split discussion.)Stats
Verification
All four pre-push checks pass:
Out of scope for follow-ups
mirror_workercrate (Sequencer/Cleaner Durable Objects, real storage). This PR is the spec layer only.tlog_tiles/tlog_coreafter the slice).TicketMacer(not needed for v1; operators bind context via the plaintext).