Add subtree_v1 ML-DSA-44 cosignatures to tlog_cosignature#227
Add subtree_v1 ML-DSA-44 cosignatures to tlog_cosignature#227lukevalenta merged 1 commit intomainfrom
Conversation
|
/bonk review |
There was a problem hiding this comment.
/bonk review
Solid PR. Workspace builds clean (cargo clippy --workspace --all-targets -- -Dwarnings -Dclippy::pedantic) and the 9 new unit tests pass. The split into algorithm-independent body builders (build_cosigned_message, timestamped_signature) plus the spec-mandated ML-DSA-44 wrapper is the right factoring for the upcoming MTC consumer described in the followup.
What's good
- Spec faithfulness.
cosigned_message_layout_unchangedis exactly the right thing to do for a wire-format type — any drift in the TLS-presentation encoding instantly invalidates every previously-produced cosignature and a regression here would be silent without this test. - Sign/verify symmetry on spec invariants.
start < endis asserted on sign and rejected on verify;timestamp == 0 when start != 0is panicked on sign and rejected on verify.verify_rejects_nonzero_timestamp_with_nonzero_starteven hand-crafts a cryptographically-valid blob with the bad(start, timestamp)pair to confirm the verifier rejects on the policy check rather than relying on the signature being wrong. - Length-bound enforcement at construction. Pushing the 255-byte cap into
KeyName::MAX_LENrather than re-checking at every encoding site is the right call:build_cosigned_message'sdebug_assert!oncosigner_nameis then a documentation-only redundancy, whilelog_origin(which is a&str, not a typedKeyName) keeps the release-modeassert!. Thetest_key_name_validationbyte-vs-character case ("é".repeat(...)covering UTF-8 byte/char skew) is the test I'd have asked for. - Key-ID pinning.
key_id_uses_signature_type_ml_dsa_44recomputes with the literal0x06rather than feedingSignatureType::MlDsa44 as u8back through, so a future bug that misroutes the algorithm byte through the enum still gets caught. pkcs8 = "=0.11.0-rc.11"pin. Tightening this to an exact pin alongside the newml-dsa = "=0.1.0-rc.8"is correct — the releasedpkcs8 0.11.0is API-incompatible with the rc.11 ed25519/ml-dsa pre-releases, and a transitive resolver would happily pick the released crate without the=.
Observations / minor
-
KeyName::MAX_LENis a public-API tightening.KeyName::newpreviously accepted names of any length subject to whitespace/+/non-empty rules; it now rejects names > 255 bytes. For a0.2-series crate that's fine, and the rationale is sound (every C2SP consumer that wire-encodes a name usesopaque<1..2^8-1>). Worth aCHANGELOG/release-notes mention whensigned_noteis next published, since downstream code that previously accepted longer names would now hitNoteError::InvalidKeyNameat construction. Not a blocker — flagging because the PR body doesn't call this out as a breaking change. -
extract_timestamp_millisaccepts blobs thatverify_subtreerejects.parse_timestamped_signature_timestampreturnsSome(_)for any blob>= 2428bytes, so a 3000-byte blob with a valid 8-byte prefix yieldsOk(Some(ts * 1000))fromextract_timestamp_milliseven though the same blob failsverify_subtree(becauseEncodedSignature::try_fromrequires exactly 2420 bytes in the tail). Same pattern exists incosignature_v1and the spec doesn't sayextract_timestamp_millishas to validate full structure, so this is consistent rather than buggy — but it means callers that useextract_timestamp_millisfor ordering/pruning before verification could be fed an attacker-chosen timestamp from a malformed blob. Documenting thatextract_timestamp_millisis a parse-only helper, not a validation gate, would prevent that misuse. -
parse_timestamped_signature_timestampusesbyteorder::ReadBytesExtafter a length check. Oncesig_blob.len() >= 8is established the read can't fail, so[..8].try_into().map(u64::from_be_bytes)would be slightly cleaner and avoid theOptionchain — but it's a wash and matches the surrounding style. -
sign_subtreeallowsstart == 0 && timestamp == 0. Spec-legal (the spec says "no statement is made"), but for the C2SP signed-note flow such a cosignature is operationally useless — a witness producing a checkpoint cosignature SHOULD set a non-zero timestamp. The doc onCheckpointSigner::signsays "trusting them not to pass zero in this path"; that comment is fine, but consider whether the inherentsign_subtreeshould also warn (in docs) thatstart == 0 && timestamp == 0is legal-but-likely-a-bug, given that the asymmetric panic onstart != 0 && timestamp != 0reads as "we enforce spec invariants here." -
SubtreeV1NoteVerifier::verifyonly handles the checkpoint case. That's the only thing aNoteVerifiercan handle (the trait gives you&[u8] msgwhich is the note body), and the doc is clear. Just noting that consumers verifying arbitrary-subtree cosignatures from non-checkpoint sources (e.g. an MTCsign-subtreeresponse embedded in a custom transport) must callverify_subtreedirectly rather than going through theNoteVerifiertrait — that's the documented contract.
crates/tlog_cosignature/src/subtree_v1.rs:296-303
The seconds/millis split between CheckpointSigner::sign (millis in) and sign_subtree (seconds in) is the right boundary, but the integer division silently truncates sub-second precision. 1_700_000_000_999 becomes 1_700_000_000 in the signed body, and a verifier comparing the extracted millis to a wall-clock timestamp will be off by up to 999ms. Per the cosignature spec the cosigned timestamp is POSIX seconds, so the truncation is correct — just want to confirm the upstream witness's freshness window accommodates this. (Not a code change request; flagging because witness implementations sometimes bake in tight clock-skew bounds.)
Nits
- crates/tlog_cosignature/src/subtree_v1.rs:451-454 —
signing_key()returnsExpandedSigningKey<MlDsa44>; the doc comment says "companion verifying key is derived viasignature::Keypair" butsignature::Keypairisn't imported in this file. The comment refers to the trait thatExpandedSigningKey::verifying_key()implements; consider rewording to "viaExpandedSigningKey::verifying_key" to make the reference resolvable from the file. - crates/tlog_cosignature/src/subtree_v1.rs:101 —
TIMESTAMPED_SIGNATURE_LEN = 8 + 2420would be clearer as8 + ml_dsa::SignatureSize::<MlDsa44>::USIZEif theml-dsaAPI exposes that constant — keeps the 2420 magic number anchored to the upstream type. If it doesn't, the explicit2420with the doc comment is fine.
|
Review posted to PR #227: #227 Summary of reviewVerdict: Solid PR. Workspace builds clean under pedantic clippy and the 9 new tests pass. Highlights of what's good:
Flagged for discussion (no blockers):
|
7a7248e to
a24be7b
Compare
|
/bonk review |
There was a problem hiding this comment.
/bonk review (re-run)
Pre-push checks reproduced clean on a fresh checkout: cargo clippy --workspace --all-targets -- -Dwarnings -Dclippy::pedantic, cargo fmt --all --check, and cargo test -p tlog_cosignature -p signed_note --lib all pass (9 new subtree_v1 tests + the new test_key_name_validation green). cargo machete not available in this runner; advisory.
The earlier review on this PR already covered the substantive observations (parse-only extract_timestamp_millis semantics, KeyName::MAX_LEN being a public-API tightening worth a CHANGELOG note, sign/verify symmetry on the start != 0 ⇒ timestamp == 0 invariant, the = pkcs8 pin, the seconds/millis truncation in CheckpointSigner::sign). I won't repeat them — those still stand. A few additions from this pass:
Things the prior review didn't flag
-
extract_timestamp_millisissubtree/v1-stricter thancosignature/v1.cosignature_v1::extract_timestamp_millis(crates/tlog_cosignature/src/cosignature_v1.rs:131) only requires 8 bytes — it'll happily decode a timestamp from a 10-byte garbage blob. The newsubtree_v1version (crates/tlog_cosignature/src/subtree_v1.rs:439-443) routes throughparse_timestamped_signature_timestamp, which requiressig_blob.len() >= TIMESTAMPED_SIGNATURE_LEN(2428 bytes). That's a closer fit to the wire format, but the earlier review's framing ("consistent withcosignature_v1") slightly understates it: the new helper is stricter than its sibling, just not strict enough to reject blobs in[2428, 3000)thatverify_subtreewill later reject for tail-length reasons. If the documented contract is "parse-only," fine; if it's "well-formed envelope," the tail check belongs here too. Pick one and document it. -
extract_timestamp_millisuses uncheckedts_secs * 1000. crates/tlog_cosignature/src/subtree_v1.rs:442 — for anyts_secs > u64::MAX / 1000(~5.85e14 — practically only attacker-supplied garbage prefixes) this overflows. In release this wraps silently and returns a bogus millis value; in debug it panics.cosignature_v1has the same shape, so this isn't a regression, but since the new version already does a length check on the blob, it's a natural place to alsochecked_mul(1000)and returnErr(NoteError::Timestamp)on overflow rather than propagating wrapped/panicking arithmetic to callers using the value for ordering or rate-limiting. -
SubtreeV1NoteVerifier: Cloneclones a 1312-byte ML-DSA-44 verifying key.CheckpointSigner::verifier()returnsBox::new(self.v.clone())(line 320), andNote::verifywill hold the boxed verifier for the lifetime of the verification call. Not a real cost (called once per checkpoint), but the Ed25519 sibling does the same with a 32-byte key, so worth knowing the constant factor here is ~40× larger. No action needed. -
build_cosigned_messagehas adebug_assertoncosigner_nameand a releaseassertonlog_origin. The asymmetry is documented in the doc comment ("KeyName::newalready enforces this") and is correct givenKeyName's new invariant. Worth confirming downstream code doesn't hand-construct aKeyName(e.g. viaserdedeserialize bypassingKeyName::new) — a quick grep didn't find any, but ifsigned_noteever addsDeserializeforKeyName, the deserializer needs to callKeyName::newto preserve the invariant thedebug_assertrelies on. -
SubtreeV1CheckpointSignerdoesn't implClone. Inconsequential —MlDsaExpandedSigningKey<MlDsa44>may not beCloneand signers aren't typically cloned — but worth noting for callers that want to share a signer across tasks (they'll need anArc).
Confirming prior-review items still apply post-fresh-build
cosigned_message_layout_unchangedis exactly the right regression test for a wire-format type. ✓verify_rejects_nonzero_timestamp_with_nonzero_startcorrectly proves the policy check fires before signature verification by hand-crafting a cryptographically-valid blob. ✓key_id_uses_signature_type_ml_dsa_44recomputes with the literal0x06. ✓pkcs8 = "=0.11.0-rc.11"is correct. ✓
Verdict
No blockers. The PR description's "spec invariants enforced at the API boundary" framing matches what the code does. The followup #1, #2, and #5 above are minor enough to land as-is and address in a separate cleanup if at all.
|
Review posted to PR #227. SummaryRe-ran the pre-push checks on a fresh checkout — clippy pedantic, fmt, and the 9 new The prior bonk review on this PR was thorough; I focused this pass on additions rather than duplication. New observations:
No blockers. |
f191628 to
1fd2c92
Compare
|
/bonk review again |
There was a problem hiding this comment.
/bonk review (third pass)
Pre-push checks pass on a fresh build: cargo clippy --workspace --all-targets -- -Dwarnings -Dclippy::pedantic, cargo fmt --all --check, cargo machete, and cargo test -p tlog_cosignature -p signed_note --lib all green. Test count is now 12 in tlog_cosignature (9 from the original PR body + 3 added since the prior review pass: extract_timestamp_millis_rejects_overflow, extract_timestamp_millis_rejects_wrong_length, subtree_constructor_rejects_misaligned_pairs).
What changed since the previous review pass
The author addressed every actionable item from the prior two review pass without introducing any other code drift. Verified against the diff:
extract_timestamp_millisoverflow safety (crates/tlog_cosignature/src/subtree_v1.rs:481) —ts_secs.checked_mul(1000).ok_or(NoteError::Timestamp)?replaces the previous uncheckedts_secs * 1000. Adversarial prefixes nearu64::MAXnow returnErr(NoteError::Timestamp)instead of wrapping in release / panicking in debug.extract_timestamp_millis_rejects_overflowpins this with au64::MAXprefix.- Parse-only / well-formed-envelope dichotomy (crates/tlog_cosignature/src/subtree_v1.rs:503) —
parse_timestamped_signature_timestampnow requiressig_blob.len() != TIMESTAMPED_SIGNATURE_LEN(exact match, not>=). The prior review's[2428, 3000)accept-but-fail-verify gap is closed: any blob whose length doesn't match the envelope exactly is rejected at the parse layer.extract_timestamp_millis_rejects_wrong_lengthpins both the too-short and too-long sides plus the exact-length acceptance with a garbage tail. The doc onextract_timestamp_millis(line 478-483) now spells out the parse-only contract: "ReturningOk(Some(_))means the blob has the correct envelope shape … It does not mean the signature itself is valid, or that the timestamp is the one the signer actually committed to." - Typed
Subtreeparameter —sign_subtreeandverify_subtreenow take&Subtree(crates/tlog_cosignature/src/subtree_v1.rs:285-291, 402-408) rather than raw(start, end)u64s.Subtree::newenforces draft-ietf-plants-merkle-tree-certs §4.1 (lo < hiplus the next-power-of-two alignment); the previous runtimestart < endcheck is now a type-system invariant.subtree_constructor_rejects_misaligned_pairspins[3, 5),[16, 100),[5, 5)rejection and[8, 16)acceptance.build_cosigned_messagekeeps the raw-u64shape with a documented contract for low-level consumers (e.g. MTC CA cosigners that already have validated subtree bounds). - Spec-legal-but-operationally-useless documentation (crates/tlog_cosignature/src/subtree_v1.rs:264-273) —
sign_subtree's doc now flags thatstart == 0 && timestamp == 0is spec-legal but useless for witness use, and explicitly says "callers producing checkpoint cosignatures for witness use must supply a non-zero timestamp themselves." That resolves the asymmetry concern (we don't enforce the symmetric panic for the spec-legal case, and now the doc says why). extract_timestamp_millisissubtree/v1-stricter thancosignature/v1— confirmed:cosignature_v1::extract_timestamp_millis(crates/tlog_cosignature/src/cosignature_v1.rs:131-137) still uses uncheckedts * 1000and reads from any>= 8byte slice. Thesubtree/v1version is now both length-tight and overflow-safe. The asymmetry is fine for this PR —cosignature_v1is unchanged and its behavior pre-existed — but worth a follow-up to bringcosignature_v1to parity. Not a blocker for this PR.
New observations from this pass
crates/tlog_cosignature/src/subtree_v1.rs:330
let subtree = Subtree::new(0, checkpoint.size()).map_err(|_| NoteError::Format)?;Reasonable mapping, but NoteError::Format reads a little generic for "the checkpoint claims size 0." A 0-sized checkpoint is the only failure mode here (Subtree::new(0, n) only fails for n == 0, since 0 & (s-1) == 0 for any power-of-two s). Either the doc on this line could note that, or a more specific error variant could be introduced — but it's a minor clarity nit, not a bug. The comment three lines up does explain the failure mode, so this is largely already addressed.
crates/tlog_cosignature/src/subtree_v1.rs:478-484
extract_timestamp_millis always returns Ok(Some(_)) on success — never Ok(None). The trait signature Result<Option<u64>, NoteError> allows three outcomes; this impl produces only two. The Ed25519 cosignature/v1 impl (crates/tlog_cosignature/src/cosignature_v1.rs:131-137) and static_ct_api's impl (crates/static_ct_api/src/static_ct.rs:579) follow the same pattern, so this is consistent with the established convention. The Ed25519NoteVerifier in signed_note/src/ed25519.rs:38 is the impl that uses Ok(None) for the "no timestamp here" case. Worth confirming the Option<u64> layer is the right factoring vs. having two separate trait methods, but that's a signed_note-level question, not something to address in this PR.
crates/tlog_cosignature/src/subtree_v1.rs:599
let msg = build_cosigned_message(&name, 12345, "log/origin", 4, 8, &Hash([0u8; HASH_SIZE]));The hand-crafted-blob test hardcodes the body parameters and assumes build_cosigned_message's signature won't drift. Since cosigned_message_layout_unchanged independently pins the byte layout, drift in build_cosigned_message's parameter order would surface there first; the test as written is fine.
KeyName invariant durability
The debug_assert! on cosigner_name's length in build_cosigned_message (line 162-165) relies on KeyName::new being the only construction path. Confirmed: KeyName(String) has a private inner field, no Deserialize impl, no public constructor other than new. The single KeyName::new call site for parsing-from-wire (crates/signed_note/src/lib.rs:471, in NoteSignature::from_bytes) routes through the constructor and so enforces the cap. If signed_note ever adds a serde::Deserialize impl for KeyName, the deserializer must call KeyName::new (or duplicate the validation) to preserve the invariant the debug_assert rests on — flagging because the convention isn't documented anywhere.
Verdict
No blockers. The PR is in noticeably better shape than it was at the first review pass: the spec invariants are now enforced both at the API boundary (typed Subtree, KeyName::MAX_LEN) and at the parse boundary (exact-length envelope check, checked_mul), with regression tests pinning each. The stacked cosignature_v1 parity follow-up (overflow safety, length tightening) is the only loose thread, and it's pre-existing rather than introduced here.
|
Review posted to PR #227. SummaryThird review pass on PR #227. All pre-push checks pass on a fresh build (clippy pedantic, fmt, machete, tests). Test count is now 12 (vs the 9 in the original PR body) — the author added the three regression tests prior reviews asked for: The author addressed every actionable item from prior passes, verified against the diff:
New observations (none are blockers):
Verdict: No blockers. PR is in noticeably better shape than at the first pass — spec invariants enforced both at the API boundary (typed |
|
Ready for human review ✨ (I'll mark future PRs as draft until AI nits are cleaned up to hopefully reduce the noise) |
Implements the `subtree/v1` cosignature format from c2sp.org/tlog-cosignature: signed-note algorithm byte `0x06` paired with ML-DSA-44. Public API in `tlog_cosignature::subtree_v1`: - `build_cosigned_message`: algorithm-independent body builder for the `cosigned_message` TLS-presentation struct. Takes raw `(start, end)` rather than a typed `&Subtree` because consumers (notably future draft-ietf-plants-merkle-tree-certs CA cosigners) may have already validated their inputs upstream and don't want a redundant construction; callers MUST ensure `[start, end)` is a valid subtree. - `timestamped_signature`: `BE u64 timestamp || raw signature` blob used inside signed-note lines. Available for callers that produce signed-note-line output with a different algorithm. - `SubtreeV1CheckpointSigner`: ML-DSA-44 signer. Implements `tlog_tiles::CheckpointSigner` for the checkpoint case; `sign_subtree` covers arbitrary subtrees and takes `&tlog_tiles::Subtree` so subtree validity is enforced by the type system rather than at runtime. - `SubtreeV1NoteVerifier`: matching ML-DSA-44 verifier. Symmetric shape: `verify_subtree` takes `&Subtree`; the `NoteVerifier::verify` path constructs `Subtree::new(0, checkpoint.size())` and bails on empty checkpoints. Spec invariants enforced at the API boundary: - `[start, end)` subtree validity (start < end, alignment to next- power-of-two width per draft-ietf-plants-merkle-tree-certs §4.1) is expressed in the type via `&Subtree`. - `timestamp != 0` when `start != 0` is rejected on verify; `sign_subtree` panics on the same combination. - `log_origin` is asserted to fit `opaque<1..2^8-1>`. The matching bound on `cosigner_name` is part of `KeyName`'s invariant. - `extract_timestamp_millis` rejects blobs whose length isn't exactly `TIMESTAMPED_SIGNATURE_LEN`, and uses `checked_mul` to guard seconds-to-millis conversion against overflow. `signed_note` changes: - New `KeyName::MAX_LEN = 255` enforced in `KeyName::new`. Pushes the TLS-presentation `opaque<1..2^8-1>` cap into the type so consumers can rely on it without re-checking. Public-API tightening (over-long names now fail at construction); call out in the next `signed_note` release notes. - New `SignatureType::MlDsa44 = 0x06`. - New `test_key_name_validation` covering empty / ASCII whitespace / Unicode whitespace / `+` / max-length acceptance / byte-vs-character distinction. Workspace plumbing: - Adds `ml-dsa = "=0.1.0-rc.8"`. The `=` pin matches the wider RustCrypto pre-release pinning convention already documented in the comment block. - Tightens `pkcs8 = "=0.11.0-rc.11"` so transitive resolvers don't pick the released `0.11.0` (whose API is incompatible with the rc.11 ed25519/ml-dsa pre-releases). - `tlog_cosignature` gains `length_prefixed` (for the TLS-style length-prefixed `cosigner_name` / `log_origin` encoding) and `ml-dsa` deps. 12 unit tests in `tlog_cosignature::subtree_v1` cover sign-then-verify roundtrips for both checkpoint and arbitrary-subtree cases, the timestamp/start invariant, mismatched-input rejection, the `NoteVerifier::verify` checkpoint reconstruction path, the `extract_timestamp_millis` envelope-length and overflow handling, the key-ID derivation against the explicit `0x06` algorithm byte, a byte-pinned regression test on the `cosigned_message` layout, and a pin on `Subtree::new`'s alignment rejection so the type-level validation can't quietly weaken.
1fd2c92 to
481d1cc
Compare
Summary
Adds the
subtree/v1cosignature format from c2sp.org/tlog-cosignature: signed-note algorithm byte0x06paired with ML-DSA-44.Public API in
tlog_cosignature::subtree_v1build_cosigned_message: algorithm-independent body builder for thecosigned_messageTLS-presentation struct. Takes raw(start, end)rather than a typed&Subtreebecause consumers (notably future draft-ietf-plants-merkle-tree-certs CA cosigners) may have already validated their inputs upstream and don't want a redundant construction; callers MUST ensure[start, end)is a valid subtree.timestamped_signature:BE u64 timestamp || raw signatureblob used inside signed-note lines. Available for callers that produce signed-note-line output with a different signature algorithm.SubtreeV1CheckpointSigner: ML-DSA-44 signer producingsubtree/v1cosignatures. Implementstlog_tiles::CheckpointSignerfor the checkpoint case;sign_subtreecovers arbitrary subtrees and takes&tlog_tiles::Subtreeso subtree validity is enforced by the type system rather than at runtime. C2SPtlog-cosignaturemandates ML-DSA-44 forsubtree/v1signed-note lines, so this type is spec-faithful rather than generic.SubtreeV1NoteVerifier: matching ML-DSA-44 verifier. Symmetric shape:verify_subtreetakes&Subtree; theNoteVerifier::verifypath constructsSubtree::new(0, checkpoint.size())and bails on empty checkpoints.Spec invariants enforced at the API boundary
[start, end)subtree validity (start < end, alignment to next-power-of-two width per draft-ietf-plants-merkle-tree-certs §4.1) is expressed in the type via&Subtree.timestamp != 0whenstart != 0is rejected on verify;sign_subtreepanics on the same combination since it indicates caller error.log_originis asserted to fitopaque<1..2^8-1>. The matching bound oncosigner_nameis part ofKeyName's invariant (see below).extract_timestamp_millisrejects blobs whose length isn't exactlyTIMESTAMPED_SIGNATURE_LEN, and useschecked_multo guard seconds-to-millis conversion against overflow.signed_notechangesKeyName::MAX_LEN = 255and enforce it inKeyName::new. The signed-note spec itself doesn't constrain length, but every C2SP consumer that embeds a key name in a length-prefixed wire format does so asopaque<1..2^8-1>—cosigner_nameandlog_originin tlog-cosignature being the motivating example. Pushing the cap into the type means consumers can rely on the invariant without re-checking, and over-long names fail closed at construction. Public-API tightening — call out in the nextsigned_noterelease notes.SignatureType::MlDsa44 = 0x06.test_key_name_validationcovers empty / ASCII whitespace / Unicode whitespace /+/ max-length acceptance / byte-vs-character distinction.Workspace plumbing
[workspace.dependencies]addsml-dsa = "=0.1.0-rc.8"(the=pin matches the wider RustCrypto pre-release pinning convention already documented in the comment block).pkcs8is tightened from"0.11.0-rc.11"to"=0.11.0-rc.11"so transitive resolvers don't pick the released0.11.0(whose API is incompatible with the rc.11 ed25519/ml-dsa pre-releases).tlog_cosignaturegainslength_prefixed(for the TLS-style length-prefixedcosigner_name/log_originencoding) andml-dsadependencies.Test coverage
12 unit tests in
tlog_cosignature::subtree_v1:start = 0) and arbitrary-subtree (start != 0) cases.NoteVerifier::verifycheckpoint reconstruction path through a realsigned_note::Note.extract_timestamp_millisenvelope-length and overflow handling.0x06algorithm byte.cosigned_messagelayout (catches any drift in the TLS presentation).Subtree::new's alignment rejection so the type-level validation can't quietly weaken.Followup
When the IETF MTC draft-04 lands, the MTC CA cosigner implementation (in a future
ietf_mtc_apicrate) will usebuild_cosigned_messagedirectly with PKIX-defined signers (Ed25519, ECDSA, RSA, ML-DSA-44, ML-DSA-65, ML-DSA-87 per the draft). The signed-note-line wrapper isn't needed for cert-embedded cosignatures (the timestamp lives in the body); MTC sign-subtree implementations will usetimestamped_signaturedirectly with their own algorithm.Testing
cargo clippy --workspace --all-targets -- -Dwarnings -Dclippy::pedantic,cargo test,cargo fmt --all --check,cargo macheteall pass locally.