Skip to content

trees/checkpoint: Add checkpoint.Checkpoint#8830

Open
beautifulentropy wants to merge 2 commits into
mainfrom
trees-checkpoint
Open

trees/checkpoint: Add checkpoint.Checkpoint#8830
beautifulentropy wants to merge 2 commits into
mainfrom
trees-checkpoint

Conversation

@beautifulentropy

@beautifulentropy beautifulentropy commented Jun 30, 2026

Copy link
Copy Markdown
Member

Add Checkpoint to represent a tlog-checkpoint note body, with .String() to serialize it and .Marshal() to serialize with field validation. Add Parse() to parse an unsigned body and Open() to verify a signed note and parse its body into a Checkpoint.

A note to reviewers: this package and its tests were written with assistance from Claude Opus 4.8. That being said, it has been reviewed and extensively revised by myself before submission.

@beautifulentropy beautifulentropy marked this pull request as ready for review June 30, 2026 17:11
@beautifulentropy beautifulentropy requested a review from a team as a code owner June 30, 2026 17:11
// serializing.
//
// https://c2sp.org/tlog-checkpoint
func (c Checkpoint) String() string {

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.

The Checkpoint type contains both a slice (Extensions) and a potentially-large array (Tree.Hash), so we should probably not be passing Checkpoints around by value. These should be pointer receivers, and Parse should return a *Checkpoint.


// validNoteLine reports whether s is a valid signed-note line: UTF-8 with no
// control character below U+0020.
func validNoteLine(s string) bool {

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.

Are checkpoints the only kind of signed note we're going to be producing or parsing? If not, then this and other helpers should probably be in a separate note package.

return "", errors.New("empty checkpoint origin")
}
if !validNoteLine(c.Origin) {
return "", errors.New("checkpoint origin contains a control character or invalid UTF-8")

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.

There'd be no need for this verbose "or" error line if validNoteLine returned a more-specific error itself. It would also allow you to avoid repeating this same error message several times below.

return "", errors.New("checkpoint extension line contains a control character or invalid UTF-8")
}
}
return c.String(), nil

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'm not sure I love this relationship between String() and Marshal(). I like having both methods! But:
a) I think that most callers of .String() (e.g. test got/want error output) expect the result of that method to be single-line; and
b) Having two ways to get the exact same result, one of which is unsafe, feels unsafe to me.

How would you feel about having .String() instead return a purposefully non-compliant single-line result, specifically for internal debugging use, while .Marshal() constructs the actual format?

// verifiers.
//
// https://c2sp.org/signed-note
func Open(signedNote []byte, verifiers note.Verifiers) (Checkpoint, *note.Note, 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.

Who are the prospective callers of this function? Why do they want access to the underlying Note, and not just the Checkpoint? They're just different in-memory views onto the same text, right?

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'd expect that the mtpublisher wants the Note in order to extract Sigs. Specifically I'd expect the mtpublisher, for each mirror, to have a note.Verifiers object with a single key matching that mirror's key. Then we would extract the validated signature from the Sigs field and save it to the checkpoints table in the mirrorSignature column.

@aarongable aarongable requested review from ezekiel and jsha July 3, 2026 00:50

@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.

Generally looks good, thanks for working on this. Sharing something that confused me, in hopes it helps other reviewers:

The signed note format defines different signature types:

  • 0x01 — Ed25519 signatures as specified by this document.
  • 0x04 — Timestamped Ed25519 checkpoint cosignatures, as specified by c2sp.org/tlog-cosignature.
  • 0x06 — Timestamped ML-DSA-44 (sub)tree cosignatures, as specified by c2sp.org/tlog-cosignature.

We want 0x04, 0x06, or both - but probably just 0x06. Importantly, 0x06 specifies a transformation to cosigned_message before signing / verifying.

We can implement our own note.Verifier that does the transfomation at verification time, and I'm assuming that's the intended followup from this PR.

Right now the tests use note.NewVerifier, which creates a key of type 0x01. Once we implement a verifier for 0x06, we'll probably want to update the tests for verisimilitude.

//
// - https://c2sp.org/tlog-checkpoint
// - https://c2sp.org/signed-note
func Parse(text string) (Checkpoint, 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.

Go style is to call methods like this "Unmarshal".

}

size, err := strconv.ParseInt(lines[1], 10, 64)
if err != nil || size < 0 || strconv.FormatInt(size, 10) != lines[1] {

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.

This FormatInt thing is a cool trick to verify the size is written minimally! I hadn't realized that ParseInt could take an optional "+" (https://pkg.go.dev/strconv#ParseInt). And presumably it also accepts leading zeroes (haven't tested). 👍🏻

Optional improvement: if each of these error cases were broken out into its own line, we could be more user-friendly in terms of specifying exactly which category of error we hit. Similar comment for the base64 decoding below.

// verifiers.
//
// https://c2sp.org/signed-note
func Open(signedNote []byte, verifiers note.Verifiers) (Checkpoint, *note.Note, 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'd expect that the mtpublisher wants the Note in order to extract Sigs. Specifically I'd expect the mtpublisher, for each mirror, to have a note.Verifiers object with a single key matching that mirror's key. Then we would extract the validated signature from the Sigs field and save it to the checkpoints table in the mirrorSignature column.

@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.

Oh, one other nit: This PR, in the description and comments, refers to the "note body", but https://github.com/C2SP/C2SP/blob/main/signed-note.md calls the same concept the "note text". We should use "note text" or "text" here too, with "note" or "signed note" for the overall object.

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