Add sign-subtree wire format to tlog_witness crate#228
Add sign-subtree wire format to tlog_witness crate#228lukevalenta wants to merge 4 commits intomainfrom
Conversation
Symmetric with the existing Note::text() accessor: returns the signatures slice in the order they appear on the wire. Useful for callers that need to inspect or count the signatures without going through Note::verify() (which requires verifiers and may error out when no known key is present). Note::from_bytes() always returns a note with at least one signature, since the signed-note wire format requires a non-empty signature block; the slice is empty only for notes constructed via Note::new() with no existing_sigs (e.g. a bare checkpoint body).
Move TlogWitnessError, MAX_CONSISTENCY_PROOF_LINES, CONTENT_TYPE_TLOG_SIZE, and the parse_proof_line helper out of add_checkpoint.rs into a new private common.rs module. The helper becomes pub(crate) so additional endpoint modules can reuse it; the public types continue to be re-exported from the crate root. Pure refactor; no behavior change. Prepares for a sibling sign_subtree module that needs the same error type, proof-line cap, and base64 hash-line parser.
|
/bonk review |
|
/bonk review |
|
@lukevalenta Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
There was a problem hiding this comment.
Wire-format-only PR is well-scoped, the refactor in commit 2 is clean, and the test set is thorough for happy-path roundtrips and bound rejections. A few correctness concerns to flag before this gets wired up to a handler.
Main concerns (inline):
-
Bare-checkpoint fallback silently accepts malformed signed notes.
parse_reference_checkpointfalls back toNote::new(bytes, &[])wheneverNote::from_bytesfails, butNote::newonly validates UTF-8 / control chars / trailing\n. A payload likeorigin\nsize\nhash\n\nGARBAGE-NOT-A-SIG\nwill failfrom_bytes(bad sig line) and then succeedNote::newas a "bare body" whosetext()includes the bogus signature line. The handler would then see a checkpoint with no signatures and route it to the stateful path, parsing what was meant to be a signed checkpoint as if the client had asked for stateful verification. -
Per the self-review on the
subtreeline: yes, the request body needs an explicit upper bound.Note::from_bytescaps the embedded checkpoint atMAX_NOTE_SIZE(1 MiB), but the header preceding the\n\nhas no size cap, and individual subtree-cosignature lines aren't bounded either (NoteSignature::from_byteswill base64-decode arbitrarily large blobs). 8 cosignature lines × N bytes each, plus 63 proof lines (small), plus header — ifbodyreaches the parser as&[u8]already in memory the worker has already paid the allocation cost, but the parser itself shouldn't compound it. Either rejectbody.len() > SOME_MAXup front or bound each line. -
u64parse accepts+0,+5, etc.parse_decimalonly rejects leading0s; Rust'su64::from_straccepts a leading+. The spec's tree-size encoding rules are stricter than that. Same issue exists inadd_checkpoint::parse_old_line, so this is a pre-existing wart you may or may not want to fix here.
Minor:
parse_signature_lineis a one-line wrapper aroundNoteSignature::from_bytes(line.as_bytes()).map_err(TlogWitnessError::Note); could be inlined at the call site for symmetry with howparse_proof_lineis used (it is non-trivial; this one isn't).- The
request_roundtrip_bare_checkpointtest asserts!body.ends_with(b"\n\n"), which is the right invariant, but the assertion only fires because the base64-encoded subtree hash never ends in\n. A more direct test would assert the exact suffix is<root_b64>\nrather than relying on the negative. - Doc comment on
SignSubtreeRequest::checkpoint(lines 87–91) says the bare-body case has emptysignatures()and baretext()— accurate, but worth explicitly noting that callers must inspectsignatures().is_empty()to decide whether the client requested stateful verification, since otherwise the two cases are indistinguishable post-parse.
Pre-push checks confirmed locally: cargo test -p tlog_witness → 27 passed.
|
Review posted. Summary of what I left on PR #228: Top-level comment highlighting the wire-format-only scope is well-handled, the refactor is clean, tests are thorough — then three concrete concerns. Inline comments:
Plus minor nits in the top-level comment: trivial |
Parsers and serializers for the optional sign-subtree endpoint described in c2sp.org/tlog-witness#sign-subtree (currently in draft at C2SP PR #245). The endpoint lets a client ask a witness to cosign a subtree of a previously-cosigned tree, scoped to a [start, end) range, accompanied by a consistency proof to a reference checkpoint and (optionally) DoS-protection cosignatures from other known witnesses. Wire format covered: - request body: subtree range line, base64 subtree hash, up to 8 subtree-cosignature lines (em-dash-prefixed), up to 63 consistency-proof hashes, blank-line separator, then a reference checkpoint that is either a full signed note or a bare body when no checkpoint signatures are supplied. - response body: identical shape to add-checkpoint's response (one or more signed-note signature lines). Bound and ordering rejection paths are exercised by tests, including a pin against the spec's literal example shape. No crypto, no witness_worker handler — those land in a follow-up that wires the parsers up to a SubtreeV1 cosigner.
Both `add-checkpoint` and `sign-subtree` request parsers gain two
defenses against hostile input:
1. **Request-body size cap.** Add `MAX_REQUEST_BODY_SIZE`
(1 MiB + 64 KiB) to `common.rs` and reject any body above that
cap up front. The line-count caps (≤63 proof lines, ≤8 cosig
lines) bound how many lines we'll process, but they don't bound
line *sizes* — `NoteSignature::from_bytes` will base64-decode any
blob the cosignature line contains, and an attacker could feed
the parser an 8-cosig × 100 MB payload. The cap covers a 1 MiB
embedded checkpoint (per `signed_note::MAX_NOTE_SIZE`) plus 64
KiB of envelope headroom for the worst-case sign-subtree
header.
2. **Stricter ASCII-decimal parsing for tree sizes.** Lift the
shared validation into a new `parse_tree_size_decimal` helper in
`common.rs` that:
- rejects empty strings
- rejects leading zeros (except for "0" itself)
- rejects any non-ASCII-digit character — notably the leading
`+` that Rust's `u64::from_str` would otherwise accept,
turning `old +0` and `subtree +0 +1` into legitimately spec-
conformant-looking but actually-rejected requests.
Both `parse_old_line` (`add_checkpoint`) and
`parse_subtree_range_line` (`sign_subtree`) now delegate to the
shared helper.
Tests: 4 new (`request_rejects_oversized_body` and
`request_rejects_leading_plus_in_*` for both endpoints).
Addresses review feedback on PR #228.
c23cde5 to
46ccb5f
Compare
|
/bonk review |
There was a problem hiding this comment.
Re-reviewing 46ccb5f. All three concerns from the previous round are addressed cleanly:
- Bare-checkpoint fallback —
parse_reference_checkpoint(sign_subtree.rs:363) now discriminates onbytes.windows(2).any(|w| w == b"\n\n")rather than falling through on aNote::from_byteserror. The newrequest_rejects_malformed_signed_checkpointtest pins the previously-silently-acceptedorigin\nsize\nhash\n\nGARBAGE-NOT-A-SIG\npayload to aNote(_)error. - Body-size cap —
MAX_REQUEST_BODY_SIZE(1 MiB + 64 KiB) lives incommon.rsand is checked up front in both parsers, withrequest_rejects_oversized_bodycovering each. u64::from_str+Naccepted — lifted intoparse_tree_size_decimal(common.rs:78) with strict ASCII-digit check, andparse_old_line/parse_subtree_range_lineboth delegate. Both sides have arequest_rejects_leading_plus_in_*test.
cargo test -p tlog_witness → 32 passed locally; clippy -Dpedantic clean; cargo fmt --all --check clean.
One small docstring nit on MAX_REQUEST_BODY_SIZE (common.rs:46): the comment says "1 MiB embedded checkpoint (per signed_note::MAX_NOTE_SIZE)" but signed_note::MAX_NOTE_SIZE is 1_000_000 bytes (decimal MB, not 1 MiB ≈ 1,048,576), and it's pub(crate)-private so the rustdoc link [\signed_note::MAX_NOTE_SIZE`]aimed at docs.rs won't resolve. Doesn't affect correctness — the cap (1,114,112 bytes) is a strict superset of any well-formed checkpoint plus envelope — but the prose and the broken intra-doc link are easy to tighten in a followup. Could either drop the link, change "1 MiB" to "1 MB" (matching the actual value), or exposeMAX_NOTE_SIZEaspub` if external crates are expected to be aware of it.
Approving as-is; nothing here blocks the wire-format-only scope. Looking forward to the handler PR.
|
Review posted to PR #228. Summary of what I left: All three concerns from my prior review are addressed cleanly in commit
Local pre-push checks pass: One minor docstring nit: the comment on |
Summary
Adds wire-format parsers and serializers for the optional
sign-subtreeendpoint described in c2sp.org/tlog-witness#sign-subtree (currently in draft at C2SP PR #245). The endpoint lets a client ask a witness to cosign a subtree of a previously-cosigned tree, scoped to a[start, end)range, accompanied by a consistency proof to a reference checkpoint and (optionally) DoS-protection cosignatures from other known witnesses.This PR is wire-format only — no crypto and no
witness_workerhandler. The handler that wires these parsers up to aSubtreeV1CheckpointSigner(PR #227) and switches the worker default to ML-DSA-44 will follow in a separate PR.Commits
signed_note: add Note::signatures() accessor— small symmetric addition next totext(). Lets callers count or inspect signatures without going throughNote::verify()(which requires verifiers and may error). Needed by the sign-subtree serializer to decide whether to emit the checkpoint signature block and to enforce the spec'sMAX_CHECKPOINT_SIGNATURES = 8cap.tlog_witness: extract shared error and helpers into common module— pure refactor. MovesTlogWitnessError,MAX_CONSISTENCY_PROOF_LINES,CONTENT_TYPE_TLOG_SIZE, andparse_proof_lineout ofadd_checkpoint.rsinto a new privatecommon.rs. No behavior change.tlog_witness: add sign-subtree wire format— the newsign_subtreemodule with parsers, serializers, and 13 unit tests (request roundtrips, response roundtrip, spec-example shape pin, all bound and ordering rejection paths).Wire format covered
subtree <start> <end>\nrange line, base64 subtree hash, up to 8 subtree-cosignature lines (em-dash-prefixed), up to 63 consistency-proof hashes, blank-line separator, then a reference checkpoint that is either a full signed note or a bare body when no checkpoint signatures are supplied.add-checkpoint's response (one or more signed-note signature lines).The parser enforces the spec's ordering rule (cosignature lines MUST appear before proof lines) and all spec-mandated upper bounds.
What's NOT in this PR
/sign-subtreeHTTP handler inwitness_worker— that comes in a follow-up.add-checkpointstill producescosignature/v1Ed25519 signatures. The follow-up PR will add ML-DSA-44 /subtree/v1support and switch the default, deriving the algorithm from the OID in the configured PEM signing key.Pre-push checks
All four pass on each commit:
cargo clippy --workspace --all-targets -- -Dwarnings -Dclippy::pedanticcargo test(27 tlog_witness tests: 14 add_checkpoint + 13 sign_subtree, plus the rest of the workspace)cargo fmt --all --checkcargo machete