Skip to content

Add ML-DSA-44 / sign-subtree support to witness_worker#229

Draft
lukevalenta wants to merge 3 commits intolvalenta/tlog-witness-sign-subtreefrom
lvalenta/witness-worker-sign-subtree
Draft

Add ML-DSA-44 / sign-subtree support to witness_worker#229
lukevalenta wants to merge 3 commits intolvalenta/tlog-witness-sign-subtreefrom
lvalenta/witness-worker-sign-subtree

Conversation

@lukevalenta
Copy link
Copy Markdown
Contributor

Summary

Wires the witness_worker to produce ML-DSA-44 subtree/v1 cosignatures and exposes the optional POST /sign-subtree endpoint when the deployed signing key is ML-DSA-44. Algorithm dispatch is driven entirely by the OID embedded in the WITNESS_SIGNING_KEY PKCS#8 PEM secret:

  • id-Ed25519cosignature/v1 signer; /sign-subtree returns 404.
  • id-ml-dsa-44subtree/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.

Stack

This PR depends on:

What's in this PR

  • 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 even the Ed25519 signer is ~470 bytes — both large enough to merit indirection.
  • build_witness_signer parses the PEM as a SecretDocument, dispatches on PrivateKeyInfoRef::algorithm.oid, and returns the appropriate signer. 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.
  • .dev.vars switched to ML-DSA-44 (deterministic seed [0x42; 32], repo-public, dev-only).
  • config.dev.json 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.
  • Pinned the dev log SPKI and the dev witness PEM in witness_worker::dev_config_tests so a rotation breaks closed.

Tests

  • 4 new unit tests in witness_worker covering 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.
  • 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.

Out of scope (potential follow-ups)

  • Subtree-cosignature DoS-protection lines (the optional cosig lines on a /sign-subtree request) are accepted on the wire but currently ignored. The handler relies on the witness's own past cosignature alone.
  • Stateful and recent-cache verification strategies for /sign-subtree (the spec lists three; we implement only the stateless one).
  • Caching of signed subtrees, rate-limiting, etc. — runtime concerns out of scope for the wire-format change.

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.
Picks up tlog_cosignature::subtree_v1 plus the signed_note KeyName
MAX_LEN cap and SignatureType::MlDsa44. Both prerequisite changes
will land via PRs #227 and #228 independently; this merge will
fold out cleanly when the resulting branch is rebased onto main.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mtc Merkle Tree Certificates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant