From 506923c7984beede36a8086864aa3c6ea7ab4eb9 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Mon, 22 Jun 2026 07:50:44 -0700 Subject: [PATCH 1/4] mtc/cosigned: add cosigned.Message --- mtc/cosigned/message.go | 120 +++++++++++++++++++++++++++++++++++ mtc/cosigned/message_test.go | 117 ++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 mtc/cosigned/message.go create mode 100644 mtc/cosigned/message_test.go diff --git a/mtc/cosigned/message.go b/mtc/cosigned/message.go new file mode 100644 index 00000000000..fe39a10da70 --- /dev/null +++ b/mtc/cosigned/message.go @@ -0,0 +1,120 @@ +// Package cosigned implements CosignedMessage from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1. +package cosigned + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + + "golang.org/x/crypto/cryptobyte" +) + +// Message represents a CosignedMessage from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1. +type Message struct { + CosignerName string + Timestamp uint64 + LogOrigin string + Start uint64 + End uint64 + SubtreeHash [sha256.Size]byte +} + +const subtreeLabel = "subtree/v1\n\x00" + +// Marshal encodes the Message as bytes. +// +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1 +// opaque HashValue[HASH_SIZE]; +// +// struct { +// uint8 label[12] = "subtree/v1\n\0"; +// opaque cosigner_name<1..2^8-1>; +// uint64 timestamp; +// opaque log_origin<1..2^8-1>; +// uint64 start; +// uint64 end; +// HashValue subtree_hash; +// } CosignedMessage; +func (message *Message) Marshal() ([]byte, error) { + if len(message.CosignerName) < 1 || len(message.CosignerName) > 255 { + return nil, fmt.Errorf("invalid cosigner_name length %d", len(message.CosignerName)) + } + if len(message.LogOrigin) < 1 || len(message.LogOrigin) > 255 { + return nil, fmt.Errorf("invalid log_origin length %d", len(message.LogOrigin)) + } + + var b cryptobyte.Builder + b.AddBytes([]byte(subtreeLabel)) + b.AddUint8LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes([]byte(message.CosignerName)) + }) + b.AddUint64(message.Timestamp) + b.AddUint8LengthPrefixed(func(child *cryptobyte.Builder) { + child.AddBytes([]byte(message.LogOrigin)) + }) + b.AddUint64(message.Start) + b.AddUint64(message.End) + b.AddBytes(message.SubtreeHash[:]) + + return b.Bytes() +} + +// Unmarshal unmarshals the input bytes into its receiver. +func (message *Message) Unmarshal(input []byte) error { + var out Message + + s := cryptobyte.String(input) + var label []byte + if !s.ReadBytes(&label, len(subtreeLabel)) { + return errors.New("invalid label") + } + if !bytes.Equal(label, []byte(subtreeLabel)) { + return errors.New("label was not subtree/v1") + } + + var cosignerName cryptobyte.String + if !s.ReadUint8LengthPrefixed(&cosignerName) { + return errors.New("invalid cosigner_name") + } + if len(cosignerName) < 1 { + return errors.New("empty cosigner_name") + } + out.CosignerName = string(cosignerName) + + if !s.ReadUint64(&out.Timestamp) { + return errors.New("invalid timestamp") + } + + var logOrigin cryptobyte.String + if !s.ReadUint8LengthPrefixed(&logOrigin) { + return errors.New("invalid log_origin") + } + if len(logOrigin) < 1 { + return errors.New("empty log_origin") + } + out.LogOrigin = string(logOrigin) + + if !s.ReadUint64(&out.Start) { + return errors.New("invalid start") + } + + if !s.ReadUint64(&out.End) { + return errors.New("invalid end") + } + + var subtreeHash []byte + if !s.ReadBytes(&subtreeHash, len(out.SubtreeHash)) { + return errors.New("invalid subtree hash") + } + copy(out.SubtreeHash[:], subtreeHash) + + if !s.Empty() { + return errors.New("trailing bytes") + } + + *message = out + return nil +} diff --git a/mtc/cosigned/message_test.go b/mtc/cosigned/message_test.go new file mode 100644 index 00000000000..7325798ee61 --- /dev/null +++ b/mtc/cosigned/message_test.go @@ -0,0 +1,117 @@ +package cosigned + +import ( + "encoding/hex" + "reflect" + "testing" +) + +func TestMessageRoundtrip(t *testing.T) { + m := Message{ + CosignerName: "alpha", + Timestamp: 1234, + LogOrigin: "beta", + Start: 999, + End: 1000, + SubtreeHash: [32]byte{}, + } + + copy(m.SubtreeHash[:], []byte("0123456789abcdef0123456789abcdef")) + + out, err := m.Marshal() + if err != nil { + t.Fatalf("marshaling: %s", err) + } + + var m2 Message + + err = m2.Unmarshal(out) + if err != nil { + t.Fatalf("unmarshaling encoded message: %s", err) + } + + if !reflect.DeepEqual(m, m2) { + t.Errorf("round-tripping message: got %#v, want %#v", + m, m2) + } +} + +func TestMarshalErrors(t *testing.T) { + m := Message{ + CosignerName: "Michigan", + Timestamp: 1337000, + LogOrigin: "Illinois", + Start: 9, + End: 87654321, + SubtreeHash: [32]byte{}, + } + + m.CosignerName = "" + _, err := m.Marshal() + if err == nil { + t.Fatalf("marshal with short CosignerName: got no error") + } + expected := "invalid cosigner_name length 0" + if err.Error() != expected { + t.Errorf("marshal with short name: got %q, want %q", err, expected) + } + + m.CosignerName = "Michigan" + m.LogOrigin = "" + + _, err = m.Marshal() + if err == nil { + t.Fatalf("marshal with short log_origin: got no error") + } + expected = "invalid log_origin length 0" + if err.Error() != expected { + t.Errorf("marshal with short log_origin: got %q, want %q", err, expected) + } +} + +func TestUnmarshalErrors(t *testing.T) { + m := Message{ + CosignerName: "Debut", + Timestamp: 55555, + LogOrigin: "Post", + Start: 11, + End: 22, + SubtreeHash: [32]byte{}, + } + + out, err := m.Marshal() + if err != nil { + t.Fatalf("marshal: %s", err) + } + t.Logf("%x", out) + + var m2 Message + err = m2.Unmarshal(out[:len(out)-1]) + if err == nil { + t.Errorf("unmarshal with short input: got no error") + } + + long := append(out, byte('a')) + err = m2.Unmarshal(long) + if err == nil { + t.Errorf("unmarshal with trailing bytes: got no error") + } + + emptyCosigner, err := hex.DecodeString("737562747265652f76310a0000000000000000d90304506f7374000000000000000b00000000000000160000000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Errorf("decoding hex: %s", err) + } + err = m2.Unmarshal(emptyCosigner) + if err == nil { + t.Errorf("unmarshal with empty cosigner_name: got no error") + } + + emptyLogOrigin, err := hex.DecodeString("737562747265652f76310a00054465627574000000000000d90300000000000000000b00000000000000160000000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Errorf("decoding hex: %s", err) + } + err = m2.Unmarshal(emptyLogOrigin) + if err == nil { + t.Errorf("unmarshal with empty log_origin: got no error") + } +} From 77a037fcef95d6a8cc1005d2425001e41714e774 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Tue, 23 Jun 2026 11:41:22 -0700 Subject: [PATCH 2/4] rename mtc -> trees --- {mtc => trees}/cosigned/message.go | 0 {mtc => trees}/cosigned/message_test.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {mtc => trees}/cosigned/message.go (100%) rename {mtc => trees}/cosigned/message_test.go (100%) diff --git a/mtc/cosigned/message.go b/trees/cosigned/message.go similarity index 100% rename from mtc/cosigned/message.go rename to trees/cosigned/message.go diff --git a/mtc/cosigned/message_test.go b/trees/cosigned/message_test.go similarity index 100% rename from mtc/cosigned/message_test.go rename to trees/cosigned/message_test.go From 9f8e2dc63aaa747c3c4b0ad3ab7b68138b0e5caf Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 26 Jun 2026 14:14:27 -0700 Subject: [PATCH 3/4] review feedback --- trees/cosigned/message.go | 3 ++ trees/cosigned/message_test.go | 53 +++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/trees/cosigned/message.go b/trees/cosigned/message.go index fe39a10da70..bef24faea49 100644 --- a/trees/cosigned/message.go +++ b/trees/cosigned/message.go @@ -26,6 +26,9 @@ const subtreeLabel = "subtree/v1\n\x00" // Marshal encodes the Message as bytes. // +// It errors if cosigner_name or log_origin are too long or too short. It does not validate semantic constraints, +// like start < end. +// // https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#section-5.3.1 // opaque HashValue[HASH_SIZE]; // diff --git a/trees/cosigned/message_test.go b/trees/cosigned/message_test.go index 7325798ee61..6e4c94608cf 100644 --- a/trees/cosigned/message_test.go +++ b/trees/cosigned/message_test.go @@ -3,6 +3,7 @@ package cosigned import ( "encoding/hex" "reflect" + "strings" "testing" ) @@ -46,26 +47,38 @@ func TestMarshalErrors(t *testing.T) { SubtreeHash: [32]byte{}, } - m.CosignerName = "" - _, err := m.Marshal() - if err == nil { - t.Fatalf("marshal with short CosignerName: got no error") - } - expected := "invalid cosigner_name length 0" - if err.Error() != expected { - t.Errorf("marshal with short name: got %q, want %q", err, expected) - } - - m.CosignerName = "Michigan" - m.LogOrigin = "" - - _, err = m.Marshal() - if err == nil { - t.Fatalf("marshal with short log_origin: got no error") - } - expected = "invalid log_origin length 0" - if err.Error() != expected { - t.Errorf("marshal with short log_origin: got %q, want %q", err, expected) + type testCase struct { + name, expected string + distorter func(target *Message) + } + + testCases := []testCase{ + {"short CosignerName", "invalid cosigner_name length 0", func(target *Message) { + target.CosignerName = "" + }}, + {"long CosignerName", "invalid cosigner_name length 256", func(target *Message) { + target.CosignerName = strings.Repeat("a", 256) + }}, + {"short LogOrigin", "invalid log_origin length 0", func(target *Message) { + target.LogOrigin = "" + }}, + {"long LogOrigin", "invalid log_origin length 256", func(target *Message) { + target.LogOrigin = strings.Repeat("a", 256) + }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m2 := m + tc.distorter(&m2) + _, err := m2.Marshal() + if err == nil { + t.Fatalf("got no error, want %q", tc.expected) + } + if err.Error() != tc.expected { + t.Errorf("marshal with short name: got %q, want %q", err, tc.expected) + } + }) } } From 0aa1dfa631d498f08a98fe13268a49ab0dc12a31 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Sun, 28 Jun 2026 21:23:49 -0700 Subject: [PATCH 4/4] review feedback --- trees/cosigned/message.go | 29 ++++++++++++++--------------- trees/cosigned/message_test.go | 18 +++++++----------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/trees/cosigned/message.go b/trees/cosigned/message.go index bef24faea49..8b2277630de 100644 --- a/trees/cosigned/message.go +++ b/trees/cosigned/message.go @@ -65,59 +65,58 @@ func (message *Message) Marshal() ([]byte, error) { return b.Bytes() } -// Unmarshal unmarshals the input bytes into its receiver. -func (message *Message) Unmarshal(input []byte) error { +// Unmarshal unmarshals the input bytes and returns a *Message. +func Unmarshal(input []byte) (*Message, error) { var out Message s := cryptobyte.String(input) var label []byte if !s.ReadBytes(&label, len(subtreeLabel)) { - return errors.New("invalid label") + return nil, errors.New("invalid label") } if !bytes.Equal(label, []byte(subtreeLabel)) { - return errors.New("label was not subtree/v1") + return nil, errors.New("label was not subtree/v1") } var cosignerName cryptobyte.String if !s.ReadUint8LengthPrefixed(&cosignerName) { - return errors.New("invalid cosigner_name") + return nil, errors.New("invalid cosigner_name") } if len(cosignerName) < 1 { - return errors.New("empty cosigner_name") + return nil, errors.New("empty cosigner_name") } out.CosignerName = string(cosignerName) if !s.ReadUint64(&out.Timestamp) { - return errors.New("invalid timestamp") + return nil, errors.New("invalid timestamp") } var logOrigin cryptobyte.String if !s.ReadUint8LengthPrefixed(&logOrigin) { - return errors.New("invalid log_origin") + return nil, errors.New("invalid log_origin") } if len(logOrigin) < 1 { - return errors.New("empty log_origin") + return nil, errors.New("empty log_origin") } out.LogOrigin = string(logOrigin) if !s.ReadUint64(&out.Start) { - return errors.New("invalid start") + return nil, errors.New("invalid start") } if !s.ReadUint64(&out.End) { - return errors.New("invalid end") + return nil, errors.New("invalid end") } var subtreeHash []byte if !s.ReadBytes(&subtreeHash, len(out.SubtreeHash)) { - return errors.New("invalid subtree hash") + return nil, errors.New("invalid subtree hash") } copy(out.SubtreeHash[:], subtreeHash) if !s.Empty() { - return errors.New("trailing bytes") + return nil, errors.New("trailing bytes") } - *message = out - return nil + return &out, nil } diff --git a/trees/cosigned/message_test.go b/trees/cosigned/message_test.go index 6e4c94608cf..b6503c21b5d 100644 --- a/trees/cosigned/message_test.go +++ b/trees/cosigned/message_test.go @@ -24,16 +24,13 @@ func TestMessageRoundtrip(t *testing.T) { t.Fatalf("marshaling: %s", err) } - var m2 Message - - err = m2.Unmarshal(out) + m2, err := Unmarshal(out) if err != nil { t.Fatalf("unmarshaling encoded message: %s", err) } - if !reflect.DeepEqual(m, m2) { - t.Errorf("round-tripping message: got %#v, want %#v", - m, m2) + if !reflect.DeepEqual(m, *m2) { + t.Errorf("round-tripping message: got %#v, want %#v", m, *m2) } } @@ -98,14 +95,13 @@ func TestUnmarshalErrors(t *testing.T) { } t.Logf("%x", out) - var m2 Message - err = m2.Unmarshal(out[:len(out)-1]) + _, err = Unmarshal(out[:len(out)-1]) if err == nil { t.Errorf("unmarshal with short input: got no error") } long := append(out, byte('a')) - err = m2.Unmarshal(long) + _, err = Unmarshal(long) if err == nil { t.Errorf("unmarshal with trailing bytes: got no error") } @@ -114,7 +110,7 @@ func TestUnmarshalErrors(t *testing.T) { if err != nil { t.Errorf("decoding hex: %s", err) } - err = m2.Unmarshal(emptyCosigner) + _, err = Unmarshal(emptyCosigner) if err == nil { t.Errorf("unmarshal with empty cosigner_name: got no error") } @@ -123,7 +119,7 @@ func TestUnmarshalErrors(t *testing.T) { if err != nil { t.Errorf("decoding hex: %s", err) } - err = m2.Unmarshal(emptyLogOrigin) + _, err = Unmarshal(emptyLogOrigin) if err == nil { t.Errorf("unmarshal with empty log_origin: got no error") }