diff --git a/cmd/mtc/log/internal/entry/entry.go b/cmd/mtc/log/internal/entry/entry.go new file mode 100644 index 000000000..2b3ad4437 --- /dev/null +++ b/cmd/mtc/log/internal/entry/entry.go @@ -0,0 +1,130 @@ +// Copyright 2026 The Tessera authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entry + +import ( + "errors" + "fmt" + + "golang.org/x/crypto/cryptobyte" +) + +// Standard log entry type constants from Section 5.2.1. +const ( + MTCLogEntryTypeNull EntryType = 0 + MTCLogEntryTypeTBSCert EntryType = 1 + + // SPEC: draft-ietf-plants-merkle-tree-certs section 5.2.1. + // "An MTCLogEntry's size SHOULD NOT exceed 65535 (2^16-1) bytes. + // Doing so may exceed size limits in common log-serving protocols, + // such as [TLOG-TILES]." + MaxMTCLogEntrySize = 1<<16 - 1 +) + +// MTCLogEntryExtension represents a single key-value metadata extension +// appended to an MTCLogEntry. +type MTCLogEntryExtension struct { + Type ExtensionType // 2-byte extension identifier + Data []byte // Opaque data payload +} + +// ExtensionType represents the Type field of an MTCLogentryExtension. +type ExtensionType uint16 + +// EntryType represents the Type field of an MTCLogentry. +type EntryType uint16 + +// MTCLogEntry represents leaf node as defined in +// draft-ietf-plants-merkle-tree-certs section 5.2.1. +type MTCLogEntry struct { + Extensions []MTCLogEntryExtension + Type EntryType // MTCLogEntryTypeNull, MTCLogEntryTypeTBSCert + EntryData []byte // Raw DER bytes of TBSCertificateLogEntry if Type is TBSCert +} + +// Marshal encodes the MTCLogEntry into TLS Presentation bytes. +// +// Returns an error if the extensions are not specs compliant, or if the +// resulting bytes do not fit in a t-log leaf. +func (e *MTCLogEntry) Marshal() ([]byte, error) { + if e.Type == MTCLogEntryTypeNull && len(e.EntryData) > 0 { + return nil, errors.New("null entry must have empty EntryData") + } + // struct {} Empty; + // + // enum { (2^16-1) } MTCLogEntryExtensionType; + // + // struct { + // MTCLogEntryExtensionType extension_type; + // opaque extension_data<0..2^16-1>; + // } MTCLogEntryExtension; + // + // enum { + // null_entry(0), tbs_cert_entry(1), (2^16-1) + // } MTCLogEntryType; + // + // struct { + // MTCLogEntryExtension extensions<0..2^16-1>; + // MTCLogEntryType type; + // select (type) { + // case null_entry: Empty; + // case tbs_cert_entry: opaque tbs_cert_entry_data[N]; + // /* May be extended with future types. */ + // } + // } MTCLogEntry; + var b cryptobyte.Builder + + // SPEC: draft-ietf-plants-merkle-tree-certs section 5.2.1. + // "The extensions list MUST appear in ascending order by extension_type and + // MUST NOT contain two extensions with the same extension_type." + b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) { + for i, ext := range e.Extensions { + if i > 0 { + prevType := e.Extensions[i-1].Type + if ext.Type == prevType { + child.SetError(fmt.Errorf("duplicate extension type %d", ext.Type)) + return + } + if ext.Type < prevType { + child.SetError(fmt.Errorf("extensions out of order: type %d appears after %d", ext.Type, prevType)) + return + } + } + child.AddUint16(uint16(ext.Type)) + // Each extension data block has its own 16-bit length prefix. + child.AddUint16LengthPrefixed(func(dataBlock *cryptobyte.Builder) { + dataBlock.AddBytes(ext.Data) + }) + } + }) + + b.AddUint16(uint16(e.Type)) + + // EntryData fills up the rest of the structure, no size prefix needed. + b.AddBytes(e.EntryData) + + res, err := b.Bytes() + if err != nil { + return nil, err + } + // SPEC: draft-ietf-plants-merkle-tree-certs section 5.2.1. + // "An MTCLogEntry's size SHOULD NOT exceed 65535 (2^16-1) bytes. + // Doing so may exceed size limits in common log-serving protocols, + // such as [TLOG-TILES]." + if l := len(res); l > MaxMTCLogEntrySize { + return nil, fmt.Errorf("log entry size %d exceeds tile limit %d", l, MaxMTCLogEntrySize) + } + return res, nil +} diff --git a/cmd/mtc/log/internal/entry/entry_test.go b/cmd/mtc/log/internal/entry/entry_test.go new file mode 100644 index 000000000..ef179b961 --- /dev/null +++ b/cmd/mtc/log/internal/entry/entry_test.go @@ -0,0 +1,270 @@ +// Copyright 2026 The Tessera authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entry + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "golang.org/x/crypto/cryptobyte" +) + +// unmarshal decodes a raw TLS presentation byte stream into an MTCLogEntry structure. +// This is kept in the test suite for verifying round-trip serialization of MTCLogEntry. +func (e *MTCLogEntry) unmarshal(data []byte) error { + s := cryptobyte.String(data) + + var extListStr cryptobyte.String + if !s.ReadUint16LengthPrefixed(&extListStr) { + return errors.New("malformed extension list length") + } + + e.Extensions = nil + for !extListStr.Empty() { + var ext MTCLogEntryExtension + if !extListStr.ReadUint16((*uint16)(&ext.Type)) { + return fmt.Errorf("failed to read extension type") + } + var extDataStr cryptobyte.String + if !extListStr.ReadUint16LengthPrefixed(&extDataStr) { + return fmt.Errorf("failed to read extension length") + } + ext.Data = append([]byte(nil), extDataStr...) + if n := len(e.Extensions); n > 0 { + if ext.Type < e.Extensions[n-1].Type { + return fmt.Errorf("mtc: entry extensions out of order (type %d after %d)", ext.Type, e.Extensions[n-1].Type) + } + if ext.Type == e.Extensions[n-1].Type { + return fmt.Errorf("mtc: duplicate entry extension type %d", ext.Type) + } + } + e.Extensions = append(e.Extensions, ext) + } + + if !s.ReadUint16((*uint16)(&e.Type)) { + return errors.New("missing entry type") + } + + if e.Type != MTCLogEntryTypeNull && e.Type != MTCLogEntryTypeTBSCert { + return fmt.Errorf("unknown or unsupported log entry type %d", e.Type) + } + + if e.Type == MTCLogEntryTypeNull && !s.Empty() { + return fmt.Errorf("null entry must have empty data") + } + + e.EntryData = append([]byte(nil), s...) + return nil +} + +func TestMTCLogEntry_RoundTrip(t *testing.T) { + tests := []struct { + name string + entry MTCLogEntry + }{ + { + name: "null entry no extensions", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeNull, + }, + }, + { + name: "tbs cert entry with sorted extensions", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeTBSCert, + EntryData: []byte("fake-der-octets"), + Extensions: []MTCLogEntryExtension{ + {Type: 1, Data: []byte("ext-1-data")}, + {Type: 5, Data: []byte("ext-5-data")}, + {Type: 10, Data: []byte("")}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.entry.Marshal() + if err != nil { + t.Fatalf("Marshal() unexpected error: %v", err) + } + + var got MTCLogEntry + if err := got.unmarshal(data); err != nil { + t.Fatalf("unmarshal() unexpected error: %v", err) + } + + if got.Type != tc.entry.Type { + t.Errorf("Type = %d, want %d", got.Type, tc.entry.Type) + } + if !bytes.Equal(got.EntryData, tc.entry.EntryData) { + t.Errorf("EntryData = %x, want %x", got.EntryData, tc.entry.EntryData) + } + + if len(got.Extensions) != len(tc.entry.Extensions) { + t.Fatalf("len(Extensions) = %d, want %d", len(got.Extensions), len(tc.entry.Extensions)) + } + for i := range got.Extensions { + if got.Extensions[i].Type != tc.entry.Extensions[i].Type || !bytes.Equal(got.Extensions[i].Data, tc.entry.Extensions[i].Data) { + t.Errorf("Extension[%d] = %+v, want %+v", i, got.Extensions[i], tc.entry.Extensions[i]) + } + } + }) + } +} + +func TestMTCLogEntry_MarshalErrors(t *testing.T) { + tests := []struct { + name string + entry MTCLogEntry + wantErr bool + }{ + { + name: "trailing data on null entry", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeNull, + EntryData: []byte("unexpected-data"), + }, + wantErr: true, + }, + { + name: "duplicate extensions with same data", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeTBSCert, + Extensions: []MTCLogEntryExtension{ + {Type: 2, Data: []byte("data-a")}, + {Type: 2, Data: []byte("data-a")}, + }, + }, + wantErr: true, + }, + { + name: "duplicate extensions with different data", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeTBSCert, + Extensions: []MTCLogEntryExtension{ + {Type: 2, Data: []byte("data-a")}, + {Type: 2, Data: []byte("data-b")}, + }, + }, + wantErr: true, + }, + { + name: "unsorted extensions", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeTBSCert, + Extensions: []MTCLogEntryExtension{ + {Type: 5, Data: []byte("data-5")}, + {Type: 1, Data: []byte("data-1")}, + }, + }, + wantErr: true, + }, + { + name: "entry size exceeds tile limit", + entry: MTCLogEntry{ + Type: MTCLogEntryTypeTBSCert, + EntryData: make([]byte, MaxMTCLogEntrySize), + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.entry.Marshal() + if (err != nil) != tc.wantErr { + t.Errorf("Marshal() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestMTCLogEntry_UnmarshalErrors(t *testing.T) { + tests := []struct { + name string + mutate func([]byte) []byte + wantErr bool + }{ + { + name: "truncated extension list length prefix", + mutate: func(b []byte) []byte { + return []byte{0x00, 0x05, 0x01} + }, + wantErr: true, + }, + { + name: "trailing data on null entry", + mutate: func(b []byte) []byte { + e := MTCLogEntry{Type: MTCLogEntryTypeNull} + data, _ := e.Marshal() + return append(data, 0x00) + }, + wantErr: true, + }, + { + name: "unknown entry type", + mutate: func(b []byte) []byte { + var builder cryptobyte.Builder + builder.AddUint16(0) // 0 length extensions + builder.AddUint16(99) // invalid type 99 + return builder.BytesOrPanic() + }, + wantErr: true, + }, + { + name: "unsorted extensions over the wire", + mutate: func(b []byte) []byte { + var builder cryptobyte.Builder + var extList cryptobyte.Builder + extList.AddUint16(5) + extList.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes([]byte("a")) }) + extList.AddUint16(1) + extList.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes([]byte("b")) }) + builder.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes(extList.BytesOrPanic()) }) + builder.AddUint16(uint16(MTCLogEntryTypeTBSCert)) + return builder.BytesOrPanic() + }, + wantErr: true, + }, + { + name: "duplicate extensions over the wire", + mutate: func(b []byte) []byte { + var builder cryptobyte.Builder + var extList cryptobyte.Builder + extList.AddUint16(2) + extList.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes([]byte("a")) }) + extList.AddUint16(2) + extList.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes([]byte("b")) }) + builder.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes(extList.BytesOrPanic()) }) + builder.AddUint16(uint16(MTCLogEntryTypeTBSCert)) + return builder.BytesOrPanic() + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var entry MTCLogEntry + err := entry.unmarshal(tc.mutate(nil)) + if (err != nil) != tc.wantErr { + t.Errorf("unmarshal() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} diff --git a/cmd/mtc/log/mtc.go b/cmd/mtc/log/mtc.go index aad56743e..1434f2ea9 100644 --- a/cmd/mtc/log/mtc.go +++ b/cmd/mtc/log/mtc.go @@ -16,17 +16,158 @@ package log import ( "context" + "crypto/sha256" + "errors" + "fmt" "github.com/transparency-dev/tessera" + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" ) type MTCLog struct { a *tessera.Appender } +// MTCProof represents an MTC inclusion proof as per +// draft-ietf-plants-merkle-tree-certs section 6.2. type MTCProof struct{} -type TBSCertificateLogEntry struct{} +// TBSCertificateLogEntry represents a log entry as per +// draft-ietf-plants-merkle-tree-certs section 7.2. +type TBSCertificateLogEntry struct { + Version int64 `json:"version"` // 0 = v1 (default), tag 0 + Issuer []byte `json:"issuer"` // Raw DER-encoded SEQUENCE + Validity []byte `json:"validity"` // Raw DER-encoded SEQUENCE + Subject []byte `json:"subject"` // Raw DER-encoded SEQUENCE + SubjectPublicKeyAlgorithm []byte `json:"subjectPublicKeyAlgorithm"` // Raw DER-encoded SEQUENCE + SubjectPublicKeyInfoHash []byte `json:"subjectPublicKeyInfoHash"` // Raw 32-byte public key hash + IssuerUniqueID []byte `json:"issuerUniqueId,omitempty"` // Optional raw IMPLICIT BIT STRING bytes, tag 1 + SubjectUniqueID []byte `json:"subjectUniqueId,omitempty"` // Optional raw IMPLICIT BIT STRING bytes, tag 2 + Extensions []byte `json:"extensions,omitempty"` // Optional raw EXPLICIT SEQUENCE bytes, tag 3 +} + +// Marshal returns the contents octets of the TBSCertificateLogEntry's +// DER encoding, i.e. WITHOUT the outer SEQUENCE tag and length prefix. +// +// SPEC: draft-ietf-plants-merkle-tree-certs section 5.2.1. +// "tbs_cert_entry_data contains the contents octets (i.e. excluding the initial +// identifier and length octets) of the DER" +func (e *TBSCertificateLogEntry) Marshal() ([]byte, error) { + // TBSCertificateLogEntry ::= SEQUENCE { + // version [0] EXPLICIT Version DEFAULT v1, + // issuer Name, + // validity Validity, + // subject Name, + // subjectPublicKeyAlgorithm AlgorithmIdentifier{PUBLIC-KEY, + // {PublicKeyAlgorithms}}, + // subjectPublicKeyInfoHash OCTET STRING, + // issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL, + // subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, + // extensions [3] EXPLICIT Extensions{{CertExtensions}} + // OPTIONAL + // } + if err := e.Validate(); err != nil { + return nil, err + } + + var b cryptobyte.Builder + + // SPEC: RFC 5280 section 4.1.2.1. + // "If only basic fields are present, the version SHOULD be 1 (the value is + // omitted from the certificate as the default value)" + // "The encoding of a set value or sequence value shall not include an + // encoding for any component value which is equal to its default value." + // + // SPEC: ITU-T X.690 section 11.5 + // "The encoding of a set value or sequence value shall not include an encoding + // for any component value which is equal to its default value." + if e.Version != 0 { + b.AddASN1(asn1.Tag(0).ContextSpecific().Constructed(), func(b *cryptobyte.Builder) { + b.AddASN1Int64(e.Version) + }) + } + + b.AddBytes(e.Issuer) + b.AddBytes(e.Validity) + b.AddBytes(e.Subject) + b.AddBytes(e.SubjectPublicKeyAlgorithm) + b.AddASN1OctetString(e.SubjectPublicKeyInfoHash) + + if len(e.IssuerUniqueID) > 0 { + b.AddBytes(e.IssuerUniqueID) + } + if len(e.SubjectUniqueID) > 0 { + b.AddBytes(e.SubjectUniqueID) + } + if len(e.Extensions) > 0 { + b.AddBytes(e.Extensions) + } + + return b.Bytes() +} + +func validateASN1Element(data []byte, expectedTag asn1.Tag, fieldName string) error { + if len(data) == 0 { + return nil + } + s := cryptobyte.String(data) + var elem cryptobyte.String + if !s.ReadASN1(&elem, expectedTag) || !s.Empty() { + return fmt.Errorf("%s: malformed ASN.1 or incorrect tag (expected %v)", fieldName, expectedTag) + } + return nil +} + +// Validate checks that TBSCertificateLogEntry fields are correct. +// It checks that mandatory fields are present, and that all fields are well +// formatted. +func (e *TBSCertificateLogEntry) Validate() error { + if e.Version < 0 || e.Version > 2 { + return fmt.Errorf("invalid version %d", e.Version) + } + + switch { + case len(e.Issuer) == 0: + return errors.New("issuer: mandatory field is missing") + case len(e.Validity) == 0: + return errors.New("validity: mandatory field is missing") + case len(e.Subject) == 0: + return errors.New("subject: mandatory field is missing") + case len(e.SubjectPublicKeyAlgorithm) == 0: + return errors.New("subjectPublicKeyAlgorithm: mandatory field is missing") + } + + if err := validateASN1Element(e.Issuer, asn1.SEQUENCE, "issuer"); err != nil { + return err + } + if err := validateASN1Element(e.Validity, asn1.SEQUENCE, "validity"); err != nil { + return err + } + if err := validateASN1Element(e.Subject, asn1.SEQUENCE, "subject"); err != nil { + return err + } + if err := validateASN1Element(e.SubjectPublicKeyAlgorithm, asn1.SEQUENCE, "subjectPublicKeyAlgorithm"); err != nil { + return err + } + if err := validateASN1Element(e.IssuerUniqueID, asn1.Tag(1).ContextSpecific(), "issuerUniqueID"); err != nil { + return err + } + if err := validateASN1Element(e.SubjectUniqueID, asn1.Tag(2).ContextSpecific(), "subjectUniqueID"); err != nil { + return err + } + if err := validateASN1Element(e.Extensions, asn1.Tag(3).ContextSpecific().Constructed(), "extensions"); err != nil { + return err + } + + // SPEC: https://c2sp.org/mtc-log + // "MTC CAs following this profile MUST use SHA-256 as the hash algorithm". + // Hence, the hash size MUST be 32 bytes. + if len(e.SubjectPublicKeyInfoHash) != sha256.Size { + return fmt.Errorf("subjectPublicKeyInfoHash must be %d bytes, got %d", sha256.Size, len(e.SubjectPublicKeyInfoHash)) + } + return nil +} // NewMTCLog creates a new MTCLog compliant with // draft-ietf-plants-merkle-tree-certs and http://c2sp.org/mtc-tlog. diff --git a/cmd/mtc/log/mtc_test.go b/cmd/mtc/log/mtc_test.go new file mode 100644 index 000000000..03bb2c29d --- /dev/null +++ b/cmd/mtc/log/mtc_test.go @@ -0,0 +1,283 @@ +// Copyright 2026 The Tessera authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "testing" + + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" +) + + +// unmarshal decodes the contents octets of a TBSCertificateLogEntry +// (i.e. WITHOUT the outer SEQUENCE tag and length prefix) into the struct. +// This is kept in the test suite for verifying round-trip serialization of TBSCertificateLogEntry. +func (e *TBSCertificateLogEntry) unmarshal(contents []byte) error { + seq := cryptobyte.String(contents) + + e.Version = 0 + versionTag := asn1.Tag(0).ContextSpecific().Constructed() + if seq.PeekASN1Tag(versionTag) { + var verWrapped cryptobyte.String + if !seq.ReadASN1(&verWrapped, versionTag) { + return fmt.Errorf("failed to read version wrapper") + } + if !verWrapped.ReadASN1Integer(&e.Version) || !verWrapped.Empty() { + return fmt.Errorf("version field is malformed") + } + } + + readElement := func(target *[]byte, tag asn1.Tag, fieldName string) error { + var rawElem cryptobyte.String + if !seq.ReadASN1Element(&rawElem, tag) { + return fmt.Errorf("failed to read %s", fieldName) + } + *target = append([]byte(nil), rawElem...) + return nil + } + + if err := readElement(&e.Issuer, asn1.SEQUENCE, "issuer"); err != nil { + return err + } + if err := readElement(&e.Validity, asn1.SEQUENCE, "validity"); err != nil { + return err + } + if err := readElement(&e.Subject, asn1.SEQUENCE, "subject"); err != nil { + return err + } + if err := readElement(&e.SubjectPublicKeyAlgorithm, asn1.SEQUENCE, "subjectPublicKeyAlgorithm"); err != nil { + return err + } + var spkiHash cryptobyte.String + if !seq.ReadASN1(&spkiHash, asn1.OCTET_STRING) { + return fmt.Errorf("failed to read subjectPublicKeyInfoHash") + } + e.SubjectPublicKeyInfoHash = append([]byte(nil), spkiHash...) + + tag1 := asn1.Tag(1).ContextSpecific() + if seq.PeekASN1Tag(tag1) { + if err := readElement(&e.IssuerUniqueID, tag1, "issuerUniqueID"); err != nil { + return err + } + } + + tag2 := asn1.Tag(2).ContextSpecific() + if seq.PeekASN1Tag(tag2) { + if err := readElement(&e.SubjectUniqueID, tag2, "subjectUniqueID"); err != nil { + return err + } + } + + tag3 := asn1.Tag(3).ContextSpecific().Constructed() + if seq.PeekASN1Tag(tag3) { + if err := readElement(&e.Extensions, tag3, "extensions"); err != nil { + return err + } + } + + if !seq.Empty() { + return fmt.Errorf("sequence has trailing bytes") + } + return nil +} + +func dummySeq(data string) []byte { + var b cryptobyte.Builder + b.AddASN1(asn1.SEQUENCE, func(b *cryptobyte.Builder) { + b.AddBytes([]byte(data)) + }) + return b.BytesOrPanic() +} + +func dummyTag(tag asn1.Tag, data string) []byte { + var b cryptobyte.Builder + b.AddASN1(tag, func(b *cryptobyte.Builder) { + b.AddBytes([]byte(data)) + }) + return b.BytesOrPanic() +} + +func TestTBSCertificateLogEntry_RoundTrip(t *testing.T) { + tests := []struct { + name string + entry TBSCertificateLogEntry + }{ + { + name: "default version 0 minimal fields", + entry: TBSCertificateLogEntry{ + Version: 0, + Issuer: dummySeq("issuer-der-seq"), + Validity: dummySeq("validity-der-seq"), + Subject: dummySeq("subject-der-seq"), + SubjectPublicKeyAlgorithm: dummySeq("spki-algo-der-seq"), + SubjectPublicKeyInfoHash: make([]byte, 32), + }, + }, + { + name: "version 2 with optional unique IDs and extensions", + entry: TBSCertificateLogEntry{ + Version: 2, + Issuer: dummySeq("issuer-der-seq"), + Validity: dummySeq("validity-der-seq"), + Subject: dummySeq("subject-der-seq"), + SubjectPublicKeyAlgorithm: dummySeq("spki-algo-der-seq"), + SubjectPublicKeyInfoHash: bytes.Repeat([]byte{0x42}, 32), + IssuerUniqueID: dummyTag(asn1.Tag(1).ContextSpecific(), "uid-1"), + SubjectUniqueID: dummyTag(asn1.Tag(2).ContextSpecific(), "uid-2"), + Extensions: dummyTag(asn1.Tag(3).ContextSpecific().Constructed(), "exts"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.entry.Marshal() + if err != nil { + t.Fatalf("Marshal() unexpected error: %v", err) + } + + var got TBSCertificateLogEntry + if err := got.unmarshal(data); err != nil { + t.Fatalf("Unmarshal() unexpected error: %v", err) + } + + if got.Version != tc.entry.Version { + t.Errorf("Version = %d, want %d", got.Version, tc.entry.Version) + } + if !bytes.Equal(got.Issuer, tc.entry.Issuer) { + t.Errorf("Issuer = %x, want %x", got.Issuer, tc.entry.Issuer) + } + if !bytes.Equal(got.Validity, tc.entry.Validity) { + t.Errorf("Validity = %x, want %x", got.Validity, tc.entry.Validity) + } + if !bytes.Equal(got.Subject, tc.entry.Subject) { + t.Errorf("Subject = %x, want %x", got.Subject, tc.entry.Subject) + } + if !bytes.Equal(got.SubjectPublicKeyAlgorithm, tc.entry.SubjectPublicKeyAlgorithm) { + t.Errorf("SubjectPublicKeyAlgorithm = %x, want %x", got.SubjectPublicKeyAlgorithm, tc.entry.SubjectPublicKeyAlgorithm) + } + if !bytes.Equal(got.SubjectPublicKeyInfoHash, tc.entry.SubjectPublicKeyInfoHash) { + t.Errorf("SubjectPublicKeyInfoHash = %x, want %x", got.SubjectPublicKeyInfoHash, tc.entry.SubjectPublicKeyInfoHash) + } + if !bytes.Equal(got.IssuerUniqueID, tc.entry.IssuerUniqueID) { + t.Errorf("IssuerUniqueID = %x, want %x", got.IssuerUniqueID, tc.entry.IssuerUniqueID) + } + if !bytes.Equal(got.SubjectUniqueID, tc.entry.SubjectUniqueID) { + t.Errorf("SubjectUniqueID = %x, want %x", got.SubjectUniqueID, tc.entry.SubjectUniqueID) + } + if !bytes.Equal(got.Extensions, tc.entry.Extensions) { + t.Errorf("Extensions = %x, want %x", got.Extensions, tc.entry.Extensions) + } + }) + } +} + +func TestTBSCertificateLogEntry_Validate(t *testing.T) { + valid := func() TBSCertificateLogEntry { + return TBSCertificateLogEntry{ + Issuer: dummySeq("issuer"), + Validity: dummySeq("validity"), + Subject: dummySeq("subject"), + SubjectPublicKeyAlgorithm: dummySeq("algo"), + SubjectPublicKeyInfoHash: make([]byte, 32), + } + } + + tests := []struct { + name string + mutate func(*TBSCertificateLogEntry) + wantErr bool + }{ + { + name: "valid", + mutate: func(e *TBSCertificateLogEntry) {}, + wantErr: false, + }, + { + name: "negative version", + mutate: func(e *TBSCertificateLogEntry) { e.Version = -1 }, + wantErr: true, + }, + { + name: "version out of bounds", + mutate: func(e *TBSCertificateLogEntry) { e.Version = 3 }, + wantErr: true, + }, + { + name: "malformed issuer asn1", + mutate: func(e *TBSCertificateLogEntry) { e.Issuer = []byte("not-asn1-sequence") }, + wantErr: true, + }, + { + name: "trailing data on issuer", + mutate: func(e *TBSCertificateLogEntry) { e.Issuer = append(dummySeq("issuer"), 0x00) }, + wantErr: true, + }, + { + name: "wrong tag on issuerUniqueID", + mutate: func(e *TBSCertificateLogEntry) { e.IssuerUniqueID = dummyTag(asn1.Tag(5).ContextSpecific(), "uid") }, + wantErr: true, + }, + { + name: "wrong tag on extensions (primitive instead of constructed)", + mutate: func(e *TBSCertificateLogEntry) { e.Extensions = dummyTag(asn1.Tag(3).ContextSpecific(), "exts") }, + wantErr: true, + }, + { + name: "missing issuer", + mutate: func(e *TBSCertificateLogEntry) { e.Issuer = nil }, + wantErr: true, + }, + { + name: "missing validity", + mutate: func(e *TBSCertificateLogEntry) { e.Validity = nil }, + wantErr: true, + }, + { + name: "missing subject", + mutate: func(e *TBSCertificateLogEntry) { e.Subject = nil }, + wantErr: true, + }, + { + name: "missing spki algo", + mutate: func(e *TBSCertificateLogEntry) { e.SubjectPublicKeyAlgorithm = nil }, + wantErr: true, + }, + { + name: "invalid hash size (short)", + mutate: func(e *TBSCertificateLogEntry) { e.SubjectPublicKeyInfoHash = make([]byte, 10) }, + wantErr: true, + }, + { + name: "invalid hash size (long)", + mutate: func(e *TBSCertificateLogEntry) { e.SubjectPublicKeyInfoHash = make([]byte, 33) }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e := valid() + tc.mutate(&e) + err := e.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +}