Add ML-DSA-44 / sign-subtree support to witness_worker#229
Draft
lukevalenta wants to merge 3 commits intolvalenta/tlog-witness-sign-subtreefrom
Draft
Add ML-DSA-44 / sign-subtree support to witness_worker#229lukevalenta wants to merge 3 commits intolvalenta/tlog-witness-sign-subtreefrom
lukevalenta wants to merge 3 commits intolvalenta/tlog-witness-sign-subtreefrom
Conversation
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.
The witness can now produce ML-DSA-44 `subtree/v1` cosignatures and exposes the optional [`POST /sign-subtree`][signsub] endpoint when configured with an ML-DSA-44 key. Algorithm dispatch is driven entirely by the OID embedded in the `WITNESS_SIGNING_KEY` PKCS#8 PEM: - `id-Ed25519` → `cosignature/v1` signer; `/sign-subtree` returns 404. - `id-ml-dsa-44` → `subtree/v1` signer; `/sign-subtree` is wired up and `/add-checkpoint` returns a `subtree/v1` cosignature covering the entire submitted tree (start = 0, end = checkpoint size), per the spec. Operators choose the algorithm purely by which key they generate; there is no separate config field. The OID is the single source of truth. Internals: - New `WitnessSigner` enum with one variant per algorithm. Both variants box their inner signer because the expanded ML-DSA-44 key is ~64 KiB and the Ed25519 signer is ~470 bytes — both large enough to merit indirection. Per-variant DER-encoded SPKI is computed once at load time and reused by `/metadata`. - `build_witness_signer` parses the PEM as a `SecretDocument`, looks at `PrivateKeyInfoRef::algorithm.oid`, and dispatches to either `Ed25519SigningKey::from_pkcs8_pem` or `MlDsaExpandedSigningKey::<MlDsa44>::from_pkcs8_pem`. Unknown OIDs produce an operator-readable error mentioning both supported OIDs. - The `/sign-subtree` handler uses **stateless** verification (the spec lists three strategies; this is the simplest): the submitted reference checkpoint must carry one of the witness's own past `subtree/v1` cosignatures. Subtree-cosignature lines from other witnesses (the optional DoS-protection mechanism) are accepted on the wire but currently ignored; the handler relies on the witness's own cosignature alone. Dev environment: - `.dev.vars` now embeds an ML-DSA-44 key derived from a published test seed (`[0x42; 32]`). - `config.dev.json` is unchanged — the log key remains Ed25519. The witness verifies log signatures with Ed25519 regardless of its own signing algorithm; only the witness's *output* changes. - The witness crate's unit tests pin both the dev log SPKI and the dev witness PEM so a rotation breaks closed. Tests: - 4 new unit tests in `witness_worker` covering the OID dispatch (Ed25519, ML-DSA-44, unsupported P-256, malformed PEM). - 1 new `dev_vars_witness_key_matches_embedded_pem` test pinning the `.dev.vars` value against the witness PEM constant. - 3 new integration test steps exercising `/sign-subtree`: happy path (cosign a subtree of a previously witness-cosigned checkpoint and verify the response), 403 when the reference checkpoint isn't cosigned by the witness, and 400 when `end > checkpoint.size`. - The existing `add-checkpoint` integration tests now verify the response with a `SubtreeV1NoteVerifier` instead of the Ed25519 one. End-to-end run against `wrangler dev` passes. [signsub]: https://c2sp.org/tlog-witness#sign-subtree
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wires the witness_worker to produce ML-DSA-44
subtree/v1cosignatures and exposes the optionalPOST /sign-subtreeendpoint when the deployed signing key is ML-DSA-44. Algorithm dispatch is driven entirely by the OID embedded in theWITNESS_SIGNING_KEYPKCS#8 PEM secret:id-Ed25519→cosignature/v1signer;/sign-subtreereturns 404.id-ml-dsa-44→subtree/v1signer;/sign-subtreeis wired up and/add-checkpointreturns asubtree/v1cosignature covering the entire submitted tree (start = 0, end = checkpoint size), per the spec.Operators choose the algorithm purely by which key they generate; there is no separate config field. The OID is the single source of truth.
Stack
This PR depends on:
lvalenta/subtree-v1) —SubtreeV1CheckpointSignerintlog_cosignature. Pulled in via the merge commit at the base of this PR.lvalenta/tlog-witness-sign-subtree) —sign_subtreewire-format parser/serializer intlog_witness. This PR is currently based on Add sign-subtree wire format to tlog_witness crate #228; it'll rebase onto main once Add subtree_v1 ML-DSA-44 cosignatures to tlog_cosignature #227 and Add sign-subtree wire format to tlog_witness crate #228 land.What's in this PR
WitnessSignerenum with one variant per algorithm. Both variants box their inner signer because the expanded ML-DSA-44 key is ~64 KiB and even the Ed25519 signer is ~470 bytes — both large enough to merit indirection.build_witness_signerparses the PEM as aSecretDocument, dispatches onPrivateKeyInfoRef::algorithm.oid, and returns the appropriate signer. Unknown OIDs produce an operator-readable error mentioning both supported OIDs./sign-subtreehandler uses stateless verification (the spec lists three strategies; this is the simplest): the submitted reference checkpoint must carry one of the witness's own pastsubtree/v1cosignatures..dev.varsswitched to ML-DSA-44 (deterministic seed[0x42; 32], repo-public, dev-only).config.dev.jsonunchanged — the log key remains Ed25519. The witness verifies log signatures with Ed25519 regardless of its own signing algorithm; only the witness's output changes.witness_worker::dev_config_testsso a rotation breaks closed.Tests
witness_workercovering OID dispatch (Ed25519, ML-DSA-44, unsupported P-256, malformed PEM).dev_vars_witness_key_matches_embedded_pemtest pinning the.dev.varsvalue./sign-subtree: happy path (cosign a subtree of a previously witness-cosigned checkpoint and verify the response), 403 when the reference checkpoint isn't cosigned by the witness, and 400 whenend > checkpoint.size.add-checkpointintegration tests now verify the response with aSubtreeV1NoteVerifierinstead of the Ed25519 one. End-to-end run againstwrangler devpasses.Out of scope (potential follow-ups)
/sign-subtreerequest) are accepted on the wire but currently ignored. The handler relies on the witness's own past cosignature alone./sign-subtree(the spec lists three; we implement only the stateless one).