trees/checkpoint: Add checkpoint.Checkpoint#8830
Conversation
634f52d to
c31714e
Compare
c31714e to
e4bc604
Compare
| // serializing. | ||
| // | ||
| // https://c2sp.org/tlog-checkpoint | ||
| func (c Checkpoint) String() string { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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] { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
Add
Checkpointto represent a tlog-checkpoint note body, with.String()to serialize it and.Marshal()to serialize with field validation. AddParse()to parse an unsigned body andOpen()to verify a signed note and parse its body into aCheckpoint.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.