Skip to content

trees/cosignature: Sign and verify MTC checkpoint cosignatures - #8904

Open
beautifulentropy wants to merge 3 commits into
mainfrom
trees-cosignature
Open

trees/cosignature: Sign and verify MTC checkpoint cosignatures#8904
beautifulentropy wants to merge 3 commits into
mainfrom
trees-cosignature

Conversation

@beautifulentropy

@beautifulentropy beautifulentropy commented Jul 24, 2026

Copy link
Copy Markdown
Member

Implement ML-DSA-44 cosignatures over log checkpoints. tlog-cosignature specifies the cosignature format, and mtc-tlog specifies how MTC uses it. This package is written to both: it derives cosigner names and log origins as mtc-tlog requires, accepts only ML-DSA-44 keys, and signs with a zero timestamp, which section 6.2 of the MTC draft requires for cosignatures used in certificates.

Export the following:

  • Cosigner cosigns checkpoints for a single log, through a crypto.Signer so the key may be stored in an HSM. .CosignCheckpoint() returns the cosignature as a timestamped_signature, which is how the MTCA signs the checkpoints it stores, and .CosignatureLine() performs the same signing but returns the cosignature as a note signature line, which is what a mirror hands back to the mtpublisher.
  • Verifier is a note.Verifier for a cosigner's public key. .Verify() satisfies the interface, checking a cosignature against checkpoint note text, and .VerifyCheckpoint() checks one against an already parsed origin and tree, which is how the MTCA verifies each cosignature before storing it.
  • Origin derives a log origin from a log ID.
  • TimestampedSignature verifies a signature line against checkpoint note text and returns the cosigner's timestamped_signature, which is how the mtpublisher turns a mirror's line into the bytes it stores.
  • RawSignature returns the bare ML-DSA-44 signature necessary for an MTCSignature, refusing non-zero timestamps. The MTCA and mtpublisher will both have a timestamped_signature in hand, they will use this to extract a raw signature to store in the database.

@beautifulentropy
beautifulentropy marked this pull request as ready for review July 27, 2026 22:42
@beautifulentropy
beautifulentropy requested a review from a team as a code owner July 27, 2026 22:42

@jsha jsha left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jumping the review queue to say: looks great to me. Only some comment nits.

// Cosigner produces cosignatures over checkpoints as an MTC cosigner for a
// single log: the timestamp is zero, as required for cosignatures used in
// certificates, and the key is a crypto.Signer so it may be stored in an HSM.
// Both the CA cosigner and a mirror cosigning the CA's log take this role.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the term "role" as used in the MTC draft is actually to distinguish between different uses of cosigners. So more precisely:

Suggested change
// Both the CA cosigner and a mirror cosigning the CA's log take this role.
// Both the CA cosigner the mirror cosigner use this.

(actually looking at https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-cosigners it says it only defines the CA cosigner role, and refers out to https://github.com/C2SP/C2SP/blob/main/tlog-mirror.md. But tlog-mirror doesn't use the word "role" anywhere 🤷🏻)

// "32473.2", nil otherwise.
func checkRelativeOID(id string) error {
if id == "" {
return errors.New("empty")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return errors.New("empty")
return errors.New("empty relative OID")

return nil, fmt.Errorf("opening the cosigned note: %s", err)
}
// verifier is the only verifier in the list, so every signature in n.Sigs
// is the cosigner's, verified and length-checked.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
// is the cosigner's, verified and length-checked.
// is by the verifier's cosigner, verified and length-checked.

Reasoning: I got a little lost reading "the cosigner" and not seeing a cosigner object in scope. Re-reading the doc comment I see we mention "verifier's cosigner" there - in other words, verifier is always assumed to have a cosigner in this code. IMO makes sense to repeat it here.

Comment on lines +155 to +156
name: oidPrefix + cosignerID,
origin: origin,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I have to look it up every time: https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-signature-format

cosigner_name and log_origin are computed from the cosigner ID and the issuance log's ID (Section 5.1), respectively.

https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-certification-authority-cos

A CA cosigner is a cosigner (Section 5.3) that certifies the contents of a log. Each CA MUST operate a CA cosigner whose cosigner ID is the same as its CA ID (Section 5.1).

So these are somewhat redundant with each other specifically in the CA cosigner case (since the log_origin is derived from the CA ID). But in the mirror case they are different: log_origin is the log being signed over, which is unrelated to the mirror's cosigner ID.

@aarongable aarongable left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with some organization nits.

// marshalCosignedMessage serializes the cosigned.Message for a checkpoint
// cosignature, with start 0 and end the tree size as the MTC draft section
// 5.3.1 requires for checkpoints. It rejects a non-positive end.
func marshalCosignedMessage(name string, timestamp uint64, origin string, end int64, rootHash tlog.Hash) ([]byte, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since this is specifically for checkpoint messages, maybe reflect that in the function name.

// deriving its cosigner name and log origin per mtc-tlog: cosigner ID "32473.2"
// signs as "oid/1.3.6.1.4.1.32473.2". It errors if either ID is not a dotted
// decimal OID or signer's public key is not ML-DSA-44.
func NewCosigner(cosignerID, logID string, signer crypto.Signer) (*Cosigner, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because log IDs must always be caID + ".0." + logNumber, maybe this function should only take the log number as input. Or maybe it should check that the logID has the cosignerID as its prefix?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #8904 (comment) - I was confused about this also. Since we'll be using this in our fake mirror also, I think we'll have cosigners where the logID doesn't have the cosignerID as its prefix - the cosignerID will belong to the mirror, while the the logID will belong to the CA.

// CosignatureLine cosigns the checkpoint described by tree and returns the
// cosignature as a signature line, trailing newline included. For a
// timestamped_signature, use CosignCheckpoint.
func (c *Cosigner) CosignatureLine(tree tlog.Tree) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this necessarily needs to be in this package. Per the PR description, the only caller of this is going to be the test mirror, so it doesn't necessarily need to be in a shared package. And this is the only caller of signatureLineFor, so that helper could be inlined into the test mirror as well. Since this is effectively test-only code, I think keeping it outside the general package makes sense.

// is not a dotted decimal OID.
//
// https://c2sp.org/mtc-tlog
func Origin(logID string) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this doesn't need to be exported.

If it were needed by other packages, then I think this wouldn't be the right home for it. Having some random package call cosignature.Origin(log.OID) feels wrong, it seems like it should be in a log package or something like that.

But I think it isn't actually needed by other packages, because you expose it as Cosigner.Origin(). For example, the usage in your followup PR can be replaced by swapping the order of those two stanzas and then calling cosigner.Origin().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants