From 73a341591cd831070986300034116803c282ddb6 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 24 Jul 2026 14:13:22 -0700 Subject: [PATCH 1/4] proof: implement MTCProof --- trees/proof/proof.go | 247 +++++++++++++++++++++++++++++ trees/proof/proof_test.go | 317 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 trees/proof/proof.go create mode 100644 trees/proof/proof_test.go diff --git a/trees/proof/proof.go b/trees/proof/proof.go new file mode 100644 index 00000000000..6a790cde53c --- /dev/null +++ b/trees/proof/proof.go @@ -0,0 +1,247 @@ +// Package proof implements the MTCProof and MTCSignature types from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-certificate-format. +// +// These are used to generate the signature field of MTCs. +package proof + +import ( + "bytes" + encoding_asn1 "encoding/asn1" + "encoding/binary" + "fmt" + "slices" + + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" + "golang.org/x/mod/sumdb/tlog" +) + +var idAlgMTCProofExperimental = encoding_asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44363, 47, 0} + +// SigAlgEncoded returns a DER-encoded AlgorithmIdentifier suitable for +// use with MTCProof signatures. Currently it returns 1.3.6.1.4.1.44363.47.0, +// per https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-certificate-format: +// +// "For initial experimentation, early implementations of this design will use the OID 1.3.6.1.4.1.44363.47.0 +// instead of id-alg-mtcProof." +// +// AlgorithmIdentifier is encoded per https://www.rfc-editor.org/info/rfc5280/#section-4.1.1.2: +// +// AlgorithmIdentifier ::= SEQUENCE { +// algorithm OBJECT IDENTIFIER, +// parameters ANY DEFINED BY algorithm OPTIONAL } +func SigAlgEncoded() []byte { + var builder cryptobyte.Builder + builder.AddASN1(asn1.SEQUENCE, func(sigAlg *cryptobyte.Builder) { + sigAlg.AddASN1ObjectIdentifier(idAlgMTCProofExperimental) + }) + return builder.BytesOrPanic() +} + +// MTCSignature represents the MTCSignature structure from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-certificate-format: +// +// /* From Section 4.1 of draft-ietf-tls-trust-anchor-ids */ +// opaque TrustAnchorID<1..2^8-1>; +// +// opaque HashValue[HASH_SIZE]; +// +// struct { +// TrustAnchorID cosigner_id; +// opaque signature<0..2^16-1>; +// } MTCSignature; +type MTCSignature struct { + CosignerID []byte + Signature []byte +} + +// Marshal serializes the bytes of an MTCSignature. +func (ms *MTCSignature) Marshal() ([]byte, error) { + var builder cryptobyte.Builder + if len(ms.CosignerID) == 0 { + // TrustAnchorID is defined as minimum 1 byte. + return nil, fmt.Errorf("empty cosigner ID") + } + builder.AddUint8LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(ms.CosignerID) + }) + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(ms.Signature) + }) + return builder.Bytes() +} + +// MTCProof represents the MTCProof structure from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-certificate-format +// +// struct { +// MTCLogEntryExtension extensions<0..2^16-1>; +// uint48 start; +// uint48 end; +// HashValue inclusion_proof<0..2^16-1>; +// MTCSignature signatures<0..2^16-1>; +// } MTCProof; +type MTCProof struct { + Extensions []byte + Start, End uint64 + InclusionProof []tlog.Hash + Signatures []*MTCSignature +} + +// Marshal serializes the bytes of an MTCProof. +func (m *MTCProof) Marshal() ([]byte, error) { + var builder cryptobyte.Builder + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + builder.AddBytes(m.Extensions) + }) + + var startBytes, endBytes [8]byte + binary.BigEndian.PutUint64(startBytes[:], m.Start) + binary.BigEndian.PutUint64(endBytes[:], m.End) + + if startBytes[0] != 0 || startBytes[1] != 0 { + return nil, fmt.Errorf("start too big: %d", m.Start) + } + if endBytes[0] != 0 || endBytes[1] != 0 { + return nil, fmt.Errorf("end too big: %d", m.End) + } + + builder.AddBytes(startBytes[2:]) + builder.AddBytes(endBytes[2:]) + + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + for _, h := range m.InclusionProof { + builder.AddBytes(h[:]) + } + }) + + // Elements [of the signatures field] MUST be ordered by cosigner_id. + signatures := slices.Clone(m.Signatures) + slices.SortFunc(signatures, func(a, b *MTCSignature) int { + if len(a.CosignerID) != len(b.CosignerID) { + return len(a.CosignerID) - len(b.CosignerID) + } + return bytes.Compare(a.CosignerID, b.CosignerID) + }) + + var sigBytes [][]byte + var previousCosignerID []byte + for _, sig := range signatures { + // Each element of the signatures field MUST have a unique cosigner_id. + // Signatures are sorted by cosigner_id, so we can just check the previous one. + if previousCosignerID != nil && bytes.Equal(sig.CosignerID, previousCosignerID) { + return nil, fmt.Errorf("duplicate cosigner ID %x", sig.CosignerID) + } + previousCosignerID = sig.CosignerID + + sigByte, err := sig.Marshal() + if err != nil { + return nil, err + } + sigBytes = append(sigBytes, sigByte) + } + + builder.AddUint16LengthPrefixed(func(builder *cryptobyte.Builder) { + for _, sb := range sigBytes { + builder.AddBytes(sb) + } + }) + + return builder.Bytes() +} + +// UnmarshalMTCProof parses the MTCProof structure from +// https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#certificate-format. +// +// The returned structure will have slices that point to the input data. +func UnmarshalMTCProof(in []byte) (*MTCProof, error) { + input := cryptobyte.String(in) + var extensions cryptobyte.String + if !input.ReadUint16LengthPrefixed(&extensions) { + return nil, fmt.Errorf("malformed MTCLogEntry extensions") + } + + var startBytesLower, endBytesLower []byte + if !input.ReadBytes(&startBytesLower, 6) { + return nil, fmt.Errorf("malformed start") + } + if !input.ReadBytes(&endBytesLower, 6) { + return nil, fmt.Errorf("malformed end") + } + + start := binary.BigEndian.Uint64(append([]byte{0, 0}, startBytesLower[:6]...)) + end := binary.BigEndian.Uint64(append([]byte{0, 0}, endBytesLower[:6]...)) + + var inclusionProofBytes cryptobyte.String + if !input.ReadUint16LengthPrefixed(&inclusionProofBytes) { + return nil, fmt.Errorf("malformed inclusion proof") + } + + var inclusionProof []tlog.Hash + for !inclusionProofBytes.Empty() { + var h []byte + if !inclusionProofBytes.ReadBytes(&h, tlog.HashSize) { + return nil, fmt.Errorf("malformed hash") + } + var hh tlog.Hash + copy(hh[:], h) + inclusionProof = append(inclusionProof, hh) + } + + var signaturesBytes cryptobyte.String + if !input.ReadUint16LengthPrefixed(&signaturesBytes) { + return nil, fmt.Errorf("malformed signature bytes") + } + + if !input.Empty() { + return nil, fmt.Errorf("trailing bytes") + } + + var signatures []*MTCSignature + var previousCosignerID cryptobyte.String + for !signaturesBytes.Empty() { + var cosignerID cryptobyte.String + if !signaturesBytes.ReadUint8LengthPrefixed(&cosignerID) { + return nil, fmt.Errorf("malformed trustAnchorID") + } + + if len(cosignerID) == 0 { + return nil, fmt.Errorf("empty cosigner ID") + } + + // Each element of the signatures field MUST have a unique cosigner_id. + if bytes.Equal(previousCosignerID, cosignerID) { + return nil, fmt.Errorf("duplicate cosigner ID") + } + + // Elements MUST be ordered by cosigner_id (excluding length prefix) as follows: + // - Shorter byte strings are ordered before longer byte strings + // - Byte strings of the same length are ordered lexicographically + if len(previousCosignerID) > len(cosignerID) { + return nil, fmt.Errorf("cosigner IDs not ordered") + } + if len(previousCosignerID) == len(cosignerID) && + bytes.Compare(previousCosignerID, cosignerID) > 0 { + return nil, fmt.Errorf("cosigner IDs not ordered") + } + previousCosignerID = cosignerID + + var sig cryptobyte.String + if !signaturesBytes.ReadUint16LengthPrefixed(&sig) { + return nil, fmt.Errorf("malformed signature") + } + + signatures = append(signatures, &MTCSignature{ + CosignerID: cosignerID, + Signature: sig, + }) + } + + return &MTCProof{ + Extensions: []byte(extensions), + Start: start, + End: end, + InclusionProof: inclusionProof, + Signatures: signatures, + }, nil +} diff --git a/trees/proof/proof_test.go b/trees/proof/proof_test.go new file mode 100644 index 00000000000..1f29080fba3 --- /dev/null +++ b/trees/proof/proof_test.go @@ -0,0 +1,317 @@ +package proof + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "strings" + "testing" + + "golang.org/x/mod/sumdb/tlog" +) + +func TestSigAlgEncoded(t *testing.T) { + // SEQUENCE { OBJECT IDENTIFIER 1.3.6.1.4.1.44363.47.0 }, parameters omitted. + expected, err := hex.DecodeString("300c060a2b0601040182da4b2f00") + if err != nil { + t.Fatal(err) + } + got := SigAlgEncoded() + if !bytes.Equal(got, expected) { + t.Errorf("SigAlgEncoded(): got %x, want %x", got, expected) + } +} + +func TestMTCSignatureMarshal(t *testing.T) { + type testCase struct { + name string + sig MTCSignature + expected string + } + + testCases := []testCase{ + { + "empty signature", + MTCSignature{CosignerID: []byte{1}}, + "0101" + "0000", + }, + { + "basic", + MTCSignature{CosignerID: []byte{1, 2}, Signature: []byte{3, 4, 5}}, + "020102" + "0003030405", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + expected, err := hex.DecodeString(tc.expected) + if err != nil { + t.Fatal(err) + } + got, err := tc.sig.Marshal() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, expected) { + t.Errorf("Marshal(): got %x, want %x", got, expected) + } + }) + } + + // TrustAnchorID has a minimum length of 1 byte, so an empty CosignerID + // must be rejected. + empty := MTCSignature{} + _, err := empty.Marshal() + if err == nil { + t.Errorf("Marshal() with empty CosignerID: got nil err, want error") + } +} + +func TestMTCProofMarshal(t *testing.T) { + var hash1, hash2 tlog.Hash + rand.Read(hash1[:]) + rand.Read(hash1[:]) + + proof := MTCProof{ + Extensions: nil, + Start: 123, + End: 456, + InclusionProof: []tlog.Hash{ + hash1, + hash2, + }, + Signatures: []*MTCSignature{ + {CosignerID: []byte{0xAA}, Signature: []byte{0xBB, 0xCC}}, + {CosignerID: []byte{0x01, 0x02}, Signature: nil}, + }, + } + + expected, err := hex.DecodeString(strings.Join([]string{ + "0000", // extensions, empty + "00000000007b", // start = 123 as uint48 + "0000000001c8", // end = 456 as uint48 + "0040", // inclusion_proof length: two 32-byte hashes + hex.EncodeToString(hash1[:]), + hex.EncodeToString(hash2[:]), + "000b", // signatures length + "01aa0002bbcc", // signature 1 + "0201020000", // signature 2 + }, "")) + if err != nil { + t.Fatal(err) + } + + got, err := proof.Marshal() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, expected) { + t.Errorf("Marshal(): got %x, want %x", got, expected) + } +} + +func TestMTCProofSignatureOrdering(t *testing.T) { + a := &MTCSignature{CosignerID: []byte{0xAA}, Signature: []byte{1}} + b := &MTCSignature{CosignerID: []byte{0x01, 0x02}, Signature: []byte{2}} + c := &MTCSignature{CosignerID: []byte{0x01, 0x03}, Signature: []byte{3}} + + expected, err := hex.DecodeString( + "0000" + "000000000000" + "000000000000" + "0000" + + "0011" + // combined byte length of the encoded signatures + "01aa000101" + // a: 1-byte ID sorts before all 2-byte IDs + "020102000102" + // b: same length as c, lexicographically first + "020103000103") // c + if err != nil { + t.Fatal(err) + } + + callerOrder := []*MTCSignature{c, a, b} + proof := MTCProof{Signatures: callerOrder} + got, err := proof.Marshal() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, expected) { + t.Errorf("Marshal(): got %x, want %x", got, expected) + } + + // The caller's slice must not have been reordered. + if callerOrder[0] != c || callerOrder[1] != a || callerOrder[2] != b { + t.Errorf("Marshal() reordered the caller's Signatures slice") + } +} + +func TestMTCProofDuplicateCosignerID(t *testing.T) { + proof := MTCProof{Signatures: []*MTCSignature{ + {CosignerID: []byte{1}, Signature: []byte{2}}, + {CosignerID: []byte{1}, Signature: []byte{3}}, + }} + _, err := proof.Marshal() + if err == nil { + t.Errorf("Marshal() with duplicate cosigner IDs: got nil err, want error") + } +} + +func TestMTCProofMarshalRange(t *testing.T) { + const maxUint48 = 1<<48 - 1 + + atLimit := MTCProof{Start: maxUint48, End: maxUint48} + _, err := atLimit.Marshal() + if err != nil { + t.Errorf("Marshal() with start and end at 2^48-1: got %s, want success", err) + } + + startTooBig := MTCProof{Start: maxUint48 + 1} + _, err = startTooBig.Marshal() + if err == nil { + t.Errorf("Marshal() with start 2^48: got nil err, want error") + } + + endTooBig := MTCProof{End: maxUint48 + 1} + _, err = endTooBig.Marshal() + if err == nil { + t.Errorf("Marshal() with end 2^48: got nil err, want error") + } +} + +// proofsEqual compares two MTCProofs field by field, treating nil and empty +// slices as equal. +func proofsEqual(t *testing.T, got, want *MTCProof) { + t.Helper() + if !bytes.Equal(got.Extensions, want.Extensions) { + t.Errorf("Extensions: got %x, want %x", got.Extensions, want.Extensions) + } + if got.Start != want.Start { + t.Errorf("Start: got %d, want %d", got.Start, want.Start) + } + if got.End != want.End { + t.Errorf("End: got %d, want %d", got.End, want.End) + } + if len(got.InclusionProof) != len(want.InclusionProof) { + t.Fatalf("InclusionProof: got %d hashes, want %d", len(got.InclusionProof), len(want.InclusionProof)) + } + for i := range got.InclusionProof { + if got.InclusionProof[i] != want.InclusionProof[i] { + t.Errorf("InclusionProof[%d]: got %x, want %x", i, got.InclusionProof[i], want.InclusionProof[i]) + } + } + if len(got.Signatures) != len(want.Signatures) { + t.Fatalf("Signatures: got %d, want %d", len(got.Signatures), len(want.Signatures)) + } + for i := range got.Signatures { + if !bytes.Equal(got.Signatures[i].CosignerID, want.Signatures[i].CosignerID) { + t.Errorf("Signatures[%d].CosignerID: got %x, want %x", + i, got.Signatures[i].CosignerID, want.Signatures[i].CosignerID) + } + if !bytes.Equal(got.Signatures[i].Signature, want.Signatures[i].Signature) { + t.Errorf("Signatures[%d].Signature: got %x, want %x", + i, got.Signatures[i].Signature, want.Signatures[i].Signature) + } + } +} + +func TestMTCProofRoundTrip(t *testing.T) { + type testCase struct { + name string + proof MTCProof + } + + testCases := []testCase{ + {"zero value", MTCProof{}}, + {"extensions only", MTCProof{Extensions: []byte{9, 9, 9}}}, + {"start and end at limit", MTCProof{Start: 1<<48 - 1, End: 1<<48 - 1}}, + {"full", MTCProof{ + Extensions: []byte{7}, + Start: 123, + End: 456, + InclusionProof: []tlog.Hash{ + {1, 2, 3}, + {4, 5, 6}, + {7, 8, 9}, + }, + Signatures: []*MTCSignature{ + {CosignerID: []byte{0xAA}, Signature: []byte{0xBB, 0xCC}}, + {CosignerID: []byte{0x01, 0x02}, Signature: []byte{0x03}}, + }, + }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + marshaled, err := tc.proof.Marshal() + if err != nil { + t.Fatal(err) + } + got, err := UnmarshalMTCProof(marshaled) + if err != nil { + t.Fatal(err) + } + proofsEqual(t, got, &tc.proof) + + remarshaled, err := got.Marshal() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(remarshaled, marshaled) { + t.Errorf("remarshal: got %x, want %x", remarshaled, marshaled) + } + }) + } +} + +func TestUnmarshalMTCProofMalformed(t *testing.T) { + // Extensions: nil, Start: 0, End: 0 + const prefix = "0000" + "000000000000" + "000000000000" + + type testCase struct { + name, input string + } + + testCases := []testCase{ + {"empty", ""}, + {"short extensions length", "00"}, + {"extensions length exceeds input", "0001"}, + {"truncated start", "0000" + "0000"}, + {"truncated end", "0000" + "000000000000" + "0000"}, + {"missing inclusion proof", "0000" + "000000000000" + "000000000000"}, + {"inclusion proof length exceeds input", prefix + "0001"}, + {"inclusion proof not a multiple of the hash size", prefix + "0001" + "00"}, + {"missing signatures", prefix + "0000"}, + {"signatures length exceeds input", prefix + "0000" + "0001"}, + {"signature missing length", prefix + "0000" + "0002" + "01aa"}, + {"signature length exceeds vector", prefix + "0000" + "0004" + "01aa0005"}, + {"trailing bytes", prefix + "0000" + "0000" + "ff"}, + // {0x01, 0x02} (2 bytes) precedes {0xAA} (1 byte): shorter IDs must + // come first. + {"cosigner IDs not ordered by length", prefix + "0000" + "0009" + "0201020000" + "01aa0000"}, + // {0x02} precedes {0x01}: same-length IDs must be ordered + // lexicographically. + {"cosigner IDs not ordered lexicographically", prefix + "0000" + "0008" + "01020000" + "01010000"}, + {"duplicate cosigner IDs", prefix + "0000" + "0008" + "01010000" + "01010000"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + input, err := hex.DecodeString(tc.input) + if err != nil { + t.Fatal(err) + } + _, err = UnmarshalMTCProof(input) + if err == nil { + t.Errorf("UnmarshalMTCProof(): got nil err, want error") + } + }) + } + + // Two cosignerIDs in the correct order, with shortest first: 0x02 comes before + // 0x01 0x02 because it's shorter. A naive comparison would put them the other way, so + // test that this parses. + input, err := hex.DecodeString(prefix + "0000" + "0009" + "01020000" + "0201020000") + if err != nil { + t.Fatal(err) + } + _, err = UnmarshalMTCProof(input) + if err != nil { + t.Errorf("UnmarshalMTCProof() with prefix-related ordered cosigner IDs: %s", err) + } +} From 08c1af44781cb5575e5660ce3cf16f25aaedd47f Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Mon, 27 Jul 2026 15:26:55 -0700 Subject: [PATCH 2/4] Fix lint --- trees/proof/proof_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trees/proof/proof_test.go b/trees/proof/proof_test.go index 1f29080fba3..a1fa10ffeb6 100644 --- a/trees/proof/proof_test.go +++ b/trees/proof/proof_test.go @@ -69,8 +69,8 @@ func TestMTCSignatureMarshal(t *testing.T) { func TestMTCProofMarshal(t *testing.T) { var hash1, hash2 tlog.Hash - rand.Read(hash1[:]) - rand.Read(hash1[:]) + _, _ = rand.Read(hash1[:]) + _, _ = rand.Read(hash1[:]) proof := MTCProof{ Extensions: nil, From f6db87409eb79c537681e911cc1be76b75fdaeb5 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 24 Jul 2026 14:13:22 -0700 Subject: [PATCH 3/4] entry: add MTCLogEntry.ToMTC() This combines a TBSCertificateLogEntry with a serial number and public key to produce an MTC. --- trees/entry/entry.go | 185 +++++++++++++++++++++++++++++++++++++- trees/entry/entry_test.go | 182 +++++++++++++++++++++++++++++++++---- 2 files changed, 347 insertions(+), 20 deletions(-) diff --git a/trees/entry/entry.go b/trees/entry/entry.go index 6f5beaf1887..89f4408457f 100644 --- a/trees/entry/entry.go +++ b/trees/entry/entry.go @@ -6,8 +6,7 @@ // // Entry bundles contain MTCLogEntry. MTCLogEntry contains (typically) TBSCertificateLogEntry. // -// TBSCertificateLogEntry is part of the X.509 layer. It will be used to build certificates -// (TODO: implement TBSCertificateLogEntry.ToX509). +// TBSCertificateLogEntry is part of the X.509 layer. It will be used to build certificates. // // MTCLogEntry is part of the Merkle tree layer and is what gets hashed. It provides type switching // (needed for null_entry) and extensibility. @@ -28,6 +27,8 @@ import ( "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/letsencrypt/boulder/trees/proof" ) const typeNullEntry = 0 @@ -55,6 +56,9 @@ type MTCLogEntry struct { // TBS returns the TBSCertificateLogEntry bytes if Type is tbs_cert_entry, or nil otherwise. func (mtcle *MTCLogEntry) TBS() []byte { + if mtcle == nil { + return nil + } if mtcle.typ == typeTBSCertEntry { return mtcle.value } @@ -227,7 +231,7 @@ func FromX509(in []byte, hash crypto.Hash) (*MTCLogEntry, error) { return nil, fmt.Errorf("malformed algorithmIdentifier") } - // Read the extensions. + // Read the extensions. We consider them required in practice. // // Note that we've ignored issuerUniqueID and subjectUniqueID, which are OPTIONAL and // forbidden by the BRs. Since those fields have encoding instructions ([1] and [2]), @@ -353,3 +357,178 @@ func (br *BundleReader) ReadEntry() (*MTCLogEntry, []byte, error) { return mtcle, body, nil } + +// ToMTC generates a Merkle Tree Certificate. +// +// `serial` must be the entry index combined with log number: (log_number << 48) | index. +// `subjectPublicKeyInfo` must be a DER-encoded subjectPublicKeyInfo (including tag and length). +// `hash` must be the CA's hash function. +// `inclusionProof` is an MTCProof that will be marshaled into the signature field. +// +// If the MTCLogEntry does not contain a TBSCertificateLogEntry, error. +// +// Checks `subjectPublicKeyInfo` against `subjectPublicKeyInfoHash` and `subjectPublicKeyAlgorithm` +// from the TBSCertificateLogEntry. Does not verify the inclusionProof. +func (mtcle *MTCLogEntry) ToMTC( + serial uint64, + subjectPublicKeyInfo []byte, + hash crypto.Hash, + inclusionProof proof.MTCProof, +) ([]byte, error) { + if mtcle == nil || mtcle.typ != typeTBSCertEntry { + return nil, fmt.Errorf("MTCLogEntry type was not tbs_cert_entry") + } + + if serial == 0 { + return nil, fmt.Errorf("serial number was zero") + } + + var spkiInner cryptobyte.String + spkiString := cryptobyte.String(subjectPublicKeyInfo) + if !spkiString.ReadASN1(&spkiInner, asn1.SEQUENCE) { + return nil, fmt.Errorf("malformed SPKI") + } + + var subjectPublicKeyAlgorithmFromSPKI cryptobyte.String + if !spkiInner.ReadASN1(&subjectPublicKeyAlgorithmFromSPKI, asn1.SEQUENCE) { + return nil, fmt.Errorf("malformed SPKI") + } + + if !bytes.Equal(mtcle.extensions, inclusionProof.Extensions) { + return nil, fmt.Errorf("MTCLogEntry extensions don't match signature") + } + + inclusionProofBytes, err := inclusionProof.Marshal() + if err != nil { + return nil, fmt.Errorf("marshaling MTC proof: %w", err) + } + + tbsCertificateLogEntry := cryptobyte.String(mtcle.TBS()) + + // 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 + // } + var version cryptobyte.String + if !tbsCertificateLogEntry.ReadASN1(&version, asn1.Tag(0).Constructed().ContextSpecific()) { + return nil, fmt.Errorf("failed to read version") + } + // Version should always be v3, which is represented as an ASN.1 INTEGER 2. + // That's tag 2, length 1, value 2. + if !bytes.Equal(version, []byte{2, 1, 2}) { + return nil, fmt.Errorf("invalid X.509 version") + } + var fields []cryptobyte.String + // issuer, validity, subject + for range 3 { + var fieldElement cryptobyte.String + var fieldTag asn1.Tag + + if !tbsCertificateLogEntry.ReadAnyASN1Element(&fieldElement, &fieldTag) { + return nil, fmt.Errorf("failed to read field") + } + + fields = append(fields, fieldElement) + } + + // Read and validate subjectPublicKeyAlgorithmFromTBS and subjectPublicKeyInfoHash. + var subjectPublicKeyAlgorithmFromTBS cryptobyte.String + if !tbsCertificateLogEntry.ReadASN1(&subjectPublicKeyAlgorithmFromTBS, asn1.SEQUENCE) { + return nil, fmt.Errorf("malformed subjectPublicKeyAlgorithm") + } + + if !bytes.Equal(subjectPublicKeyAlgorithmFromSPKI, subjectPublicKeyAlgorithmFromTBS) { + return nil, fmt.Errorf("mismatched subjectPublicKeyAlgorithm in TBS (%x) and in SPKI (%x)", + subjectPublicKeyAlgorithmFromTBS, subjectPublicKeyAlgorithmFromSPKI) + } + + var subjectPublicKeyInfoHash cryptobyte.String + if !tbsCertificateLogEntry.ReadASN1(&subjectPublicKeyInfoHash, asn1.OCTET_STRING) { + return nil, fmt.Errorf("malformed subjectPublicKeyInfoHash") + } + + h := hash.New() + h.Write(subjectPublicKeyInfo) + spkiHash := h.Sum(nil) + + if !bytes.Equal([]byte(subjectPublicKeyInfoHash), spkiHash) { + return nil, fmt.Errorf("mismatched subjectPublicKeyInfoHash: %x vs %x", + subjectPublicKeyInfoHash, spkiHash) + } + + // Read the extensions. We consider them required in practice. + // + // Note that we've ignored issuerUniqueID and subjectUniqueID, which are OPTIONAL and + // forbidden by the BRs. Since those fields have encoding instructions ([1] and [2]), + // if by some chance they are present we will error when trying to read extensions, + // which has an encoding instruction of [3]. + var extensions cryptobyte.String + extensionsTag := asn1.Tag(3).Constructed().ContextSpecific() + if !tbsCertificateLogEntry.ReadASN1Element(&extensions, extensionsTag) { + return nil, fmt.Errorf("error reading extensions") + } + + if !tbsCertificateLogEntry.Empty() { + return nil, fmt.Errorf("extra bytes at end") + } + + // https://datatracker.ietf.org/doc/html/rfc5280#page-117 + // TBSCertificate ::= SEQUENCE { + // version [0] Version DEFAULT v1, + // serialNumber CertificateSerialNumber, + // signature AlgorithmIdentifier, + // issuer Name, + // validity Validity, + // subject Name, + // subjectPublicKeyInfo SubjectPublicKeyInfo, + // issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL, + // -- If present, version MUST be v2 or v3 + // subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, + // -- If present, version MUST be v2 or v3 + // extensions [3] Extensions OPTIONAL + // -- If present, version MUST be v3 -- } + var builder cryptobyte.Builder + + builder.AddASN1(asn1.SEQUENCE, func(certificate *cryptobyte.Builder) { + certificate.AddASN1(asn1.SEQUENCE, func(tbsCertificate *cryptobyte.Builder) { + // version + tbsCertificate.AddASN1(asn1.Tag(0).Constructed().ContextSpecific(), func(child *cryptobyte.Builder) { + child.AddASN1Int64(2) + }) + + // serialNumber + tbsCertificate.AddASN1Uint64(serial) + + // signature + tbsCertificate.AddBytes(proof.SigAlgEncoded()) + + // issuer, validity, subject + for _, f := range fields { + // The fields were read with ReadASN1Element so they still include + // their tag and length. Add them straight to the builder. + tbsCertificate.AddBytes(f) + } + + // subjectPublicKeyInfo + tbsCertificate.AddBytes(subjectPublicKeyInfo) + + // extensions + tbsCertificate.AddBytes(extensions) + }) + // signatureAlgorithm + certificate.AddBytes(proof.SigAlgEncoded()) + // signature + certificate.AddASN1BitString(inclusionProofBytes) + }) + + return builder.Bytes() +} diff --git a/trees/entry/entry_test.go b/trees/entry/entry_test.go index b79f99d671c..0c10f4a1273 100644 --- a/trees/entry/entry_test.go +++ b/trees/entry/entry_test.go @@ -3,17 +3,24 @@ package entry import ( "bytes" "crypto" + "crypto/x509" "encoding/base64" "encoding/hex" "errors" "io" + "math/big" + "reflect" "strings" "testing" + + "golang.org/x/mod/sumdb/tlog" + + "github.com/letsencrypt/boulder/trees/proof" ) -func TestFromX509(t *testing.T) { - // Bytes from an example generated test/certs/ipki/wfe.boulder/cert.pem - input, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(` +// testCertB64 contains an example certificate generated from +// test/certs/ipki/wfe.boulder/cert.pem. +const testCertB64 = ` MIIB4TCCAWegAwIBAgIIJuDkMteShO8wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV bWluaWNhIHJvb3QgY2EgNGU0YjFkMB4XDTI2MDYyOTA1NDUyNloXDTI4MDcyOTA1 NDUyNlowFjEUMBIGA1UEAxMLd2ZlLmJvdWxkZXIwdjAQBgcqhkjOPQIBBgUrgQQA @@ -24,10 +31,26 @@ KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSA+ZfinkHdxJDZuRo1 zJ7mHOmaCDAWBgNVHREEDzANggt3ZmUuYm91bGRlcjAKBggqhkjOPQQDAwNoADBl AjEApE8cwaAQ6hnGtUM/TWAb54E5/29ZVy5E/UY8mEzoE021pl3tq1fEof5qz5n/ KrL4AjAuEpVOjRrRWWMnRJxd05Pfxq7gZmxgwppjnE9JZ9P6WRP7ZWqZcc9p8YLM -YhKuXQo=`, "\n", "")) +YhKuXQo=` + +// testSPKIB64 contains the SubjectPublicKeyInfo from testCertB64. +const testSPKIB64 = ` +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEy5GNcAaxpffo4UePh0snEb+ZL1VfC1Dt +8WNbLcrvPqvZGkotN8K3R+y1H6yZEROkutF81V5Hqi/Tf/4zlxbGjPSW7G3hxUaA +MfpgRFevFbuvXW5cbVyACjZTDOXWr+Vz` + +// mustDecodeB64 decodes a base64 string that may contain embedded newlines. +func mustDecodeB64(t *testing.T, in string) []byte { + t.Helper() + out, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(in, "\n", "")) if err != nil { t.Fatal(err) } + return out +} + +func TestFromX509(t *testing.T) { + input := mustDecodeB64(t, testCertB64) mtcle, err := FromX509(input, crypto.SHA256) if err != nil { @@ -57,23 +80,148 @@ AQUFBwMCMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUgPmX4p5B3cSQ2bkaNcye } } -func TestFromX509Malformed(t *testing.T) { - valid, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(` -MIIB4TCCAWegAwIBAgIIJuDkMteShO8wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV -bWluaWNhIHJvb3QgY2EgNGU0YjFkMB4XDTI2MDYyOTA1NDUyNloXDTI4MDcyOTA1 -NDUyNlowFjEUMBIGA1UEAxMLd2ZlLmJvdWxkZXIwdjAQBgcqhkjOPQIBBgUrgQQA -IgNiAATLkY1wBrGl9+jhR4+HSycRv5kvVV8LUO3xY1styu8+q9kaSi03wrdH7LUf -rJkRE6S60XzVXkeqL9N//jOXFsaM9JbsbeHFRoAx+mBEV68Vu69dblxtXIAKNlMM -5dav5XOjeDB2MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI -KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSA+ZfinkHdxJDZuRo1 -zJ7mHOmaCDAWBgNVHREEDzANggt3ZmUuYm91bGRlcjAKBggqhkjOPQQDAwNoADBl -AjEApE8cwaAQ6hnGtUM/TWAb54E5/29ZVy5E/UY8mEzoE021pl3tq1fEof5qz5n/ -KrL4AjAuEpVOjRrRWWMnRJxd05Pfxq7gZmxgwppjnE9JZ9P6WRP7ZWqZcc9p8YLM -YhKuXQo=`, "\n", "")) +func TestToMTC(t *testing.T) { + input := mustDecodeB64(t, testCertB64) + spki := mustDecodeB64(t, testSPKIB64) + + mtcle, err := FromX509(input, crypto.SHA256) if err != nil { t.Fatal(err) } + sig := proof.MTCProof{ + Start: 123, + End: 456, + InclusionProof: []tlog.Hash{ + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + }, + } + + out, err := mtcle.ToMTC(123, spki, crypto.SHA256, sig) + if err != nil { + t.Fatal(err) + } + + // Rather than comparing against golden bytes, check the output + // structurally: it must be a well-formed X.509 certificate whose issuer, + // validity, subject, SPKI, and extensions match the original certificate, + // with the caller-provided serial and signature. + cert, err := x509.ParseCertificate(out) + if err != nil { + t.Fatalf("parsing ToMTC() output: %s", err) + } + orig, err := x509.ParseCertificate(input) + if err != nil { + t.Fatal(err) + } + + if cert.SerialNumber.Cmp(big.NewInt(123)) != 0 { + t.Errorf("serialNumber: got %v, want 123", cert.SerialNumber) + } + if !bytes.Equal(cert.RawIssuer, orig.RawIssuer) { + t.Errorf("issuer: got %x, want %x", cert.RawIssuer, orig.RawIssuer) + } + if !cert.NotBefore.Equal(orig.NotBefore) || !cert.NotAfter.Equal(orig.NotAfter) { + t.Errorf("validity: got %s/%s, want %s/%s", + cert.NotBefore, cert.NotAfter, orig.NotBefore, orig.NotAfter) + } + if !bytes.Equal(cert.RawSubject, orig.RawSubject) { + t.Errorf("subject: got %x, want %x", cert.RawSubject, orig.RawSubject) + } + if !bytes.Equal(cert.RawSubjectPublicKeyInfo, spki) { + t.Errorf("subjectPublicKeyInfo: got %x, want %x", cert.RawSubjectPublicKeyInfo, spki) + } + if !reflect.DeepEqual(cert.Extensions, orig.Extensions) { + t.Errorf("extensions: got %v, want %v", cert.Extensions, orig.Extensions) + } + + // The signature must be the marshaled MTCProof. + sigBytes, err := sig.Marshal() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(cert.Signature, sigBytes) { + t.Errorf("signature: got %x, want %x", cert.Signature, sigBytes) + } + + // Both the TBSCertificate signature field and the Certificate + // signatureAlgorithm field must be the experimental id-alg-mtcProof + // AlgorithmIdentifier (1.3.6.1.4.1.44363.47.0) with parameters omitted. + // x509.ParseCertificate already checks that the two are equal. + sigAlg := proof.SigAlgEncoded() + if got := bytes.Count(out, sigAlg); got != 2 { + t.Errorf("id-alg-mtcProof AlgorithmIdentifier: found %d copies, want 2", got) + } +} + +func TestToMTCMalformed(t *testing.T) { + input := mustDecodeB64(t, testCertB64) + spki := mustDecodeB64(t, testSPKIB64) + + valid, err := FromX509(input, crypto.SHA256) + if err != nil { + t.Fatal(err) + } + + // mutatedTBS returns a tbs_cert_entry whose TBSCertificateLogEntry bytes + // are a copy of the valid entry's, transformed by f. + mutatedTBS := func(f func([]byte) []byte) *MTCLogEntry { + return &MTCLogEntry{ + typ: typeTBSCertEntry, + value: f(bytes.Clone(valid.value)), + } + } + + // mutatedSPKI returns a copy of spki with the byte at index i flipped. + mutatedSPKI := func(i int) []byte { + out := bytes.Clone(spki) + out[i] ^= 0xff + return out + } + + type testCase struct { + name string + mtcle *MTCLogEntry + spki []byte + hash crypto.Hash + } + + testCases := []testCase{ + {"null entry", &MTCLogEntry{}, spki, crypto.SHA256}, + {"truncated TBS", mutatedTBS(func(v []byte) []byte { return v[:len(v)-2] }), spki, crypto.SHA256}, + {"trailing byte in TBS", mutatedTBS(func(v []byte) []byte { return append(v, 0) }), spki, crypto.SHA256}, + // The version is the first field of the TBSCertificateLogEntry, + // encoded as a0 03 02 01 02; overwrite the value byte (v2 not v3). + {"wrong version", mutatedTBS(func(v []byte) []byte { v[4] = 1; return v }), spki, crypto.SHA256}, + {"empty SPKI", valid, nil, crypto.SHA256}, + {"truncated SPKI", valid, spki[:8], crypto.SHA256}, + // Index 8 is inside the id-ecPublicKey OID within the SPKI's + // AlgorithmIdentifier, so it must fail the algorithm comparison. + {"SPKI with different algorithm", valid, mutatedSPKI(8), crypto.SHA256}, + // The last byte is inside the public key BIT STRING, so the + // AlgorithmIdentifiers match but the hash must not. + {"SPKI with different key", valid, mutatedSPKI(len(spki) - 1), crypto.SHA256}, + {"wrong hash function", valid, spki, crypto.SHA384}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.mtcle.ToMTC(123, tc.spki, tc.hash, proof.MTCProof{}) + if err == nil { + t.Errorf("ToMTC(): got nil err, want error") + } + }) + } + + // serialNumber must be a positive integer (RFC 5280 4.1.2.2). + if _, err := valid.ToMTC(0, spki, crypto.SHA256, proof.MTCProof{}); err == nil { + t.Errorf("ToMTC() with zero serial: got nil err, want error") + } +} + +func TestFromX509Malformed(t *testing.T) { + valid := mustDecodeB64(t, testCertB64) + shortValid := valid[:len(valid)-2] longValid := append(valid, 0) From e8d9edbc8608676314f5ba1edf6fe040527fd15e Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Mon, 27 Jul 2026 21:37:04 -0700 Subject: [PATCH 4/4] review feedback: become ToTBSCertificate --- trees/entry/entry.go | 61 ++++++++++++++------------------------- trees/entry/entry_test.go | 59 +++++++++++++++---------------------- 2 files changed, 45 insertions(+), 75 deletions(-) diff --git a/trees/entry/entry.go b/trees/entry/entry.go index 89f4408457f..89c8a109819 100644 --- a/trees/entry/entry.go +++ b/trees/entry/entry.go @@ -358,22 +358,20 @@ func (br *BundleReader) ReadEntry() (*MTCLogEntry, []byte, error) { return mtcle, body, nil } -// ToMTC generates a Merkle Tree Certificate. +// ToTBSCertificate generates a TBSCertificate corresponding to a TBSCertificateLogEntry. // // `serial` must be the entry index combined with log number: (log_number << 48) | index. // `subjectPublicKeyInfo` must be a DER-encoded subjectPublicKeyInfo (including tag and length). // `hash` must be the CA's hash function. -// `inclusionProof` is an MTCProof that will be marshaled into the signature field. // // If the MTCLogEntry does not contain a TBSCertificateLogEntry, error. // // Checks `subjectPublicKeyInfo` against `subjectPublicKeyInfoHash` and `subjectPublicKeyAlgorithm` -// from the TBSCertificateLogEntry. Does not verify the inclusionProof. -func (mtcle *MTCLogEntry) ToMTC( +// from the TBSCertificateLogEntry. +func (mtcle *MTCLogEntry) ToTBSCertificate( serial uint64, subjectPublicKeyInfo []byte, hash crypto.Hash, - inclusionProof proof.MTCProof, ) ([]byte, error) { if mtcle == nil || mtcle.typ != typeTBSCertEntry { return nil, fmt.Errorf("MTCLogEntry type was not tbs_cert_entry") @@ -394,15 +392,6 @@ func (mtcle *MTCLogEntry) ToMTC( return nil, fmt.Errorf("malformed SPKI") } - if !bytes.Equal(mtcle.extensions, inclusionProof.Extensions) { - return nil, fmt.Errorf("MTCLogEntry extensions don't match signature") - } - - inclusionProofBytes, err := inclusionProof.Marshal() - if err != nil { - return nil, fmt.Errorf("marshaling MTC proof: %w", err) - } - tbsCertificateLogEntry := cryptobyte.String(mtcle.TBS()) // TBSCertificateLogEntry ::= SEQUENCE { @@ -498,36 +487,30 @@ func (mtcle *MTCLogEntry) ToMTC( // -- If present, version MUST be v3 -- } var builder cryptobyte.Builder - builder.AddASN1(asn1.SEQUENCE, func(certificate *cryptobyte.Builder) { - certificate.AddASN1(asn1.SEQUENCE, func(tbsCertificate *cryptobyte.Builder) { - // version - tbsCertificate.AddASN1(asn1.Tag(0).Constructed().ContextSpecific(), func(child *cryptobyte.Builder) { - child.AddASN1Int64(2) - }) + builder.AddASN1(asn1.SEQUENCE, func(tbsCertificate *cryptobyte.Builder) { + // version + tbsCertificate.AddASN1(asn1.Tag(0).Constructed().ContextSpecific(), func(child *cryptobyte.Builder) { + child.AddASN1Int64(2) + }) - // serialNumber - tbsCertificate.AddASN1Uint64(serial) + // serialNumber + tbsCertificate.AddASN1Uint64(serial) - // signature - tbsCertificate.AddBytes(proof.SigAlgEncoded()) + // signature + tbsCertificate.AddBytes(proof.SigAlgEncoded()) - // issuer, validity, subject - for _, f := range fields { - // The fields were read with ReadASN1Element so they still include - // their tag and length. Add them straight to the builder. - tbsCertificate.AddBytes(f) - } + // issuer, validity, subject + for _, f := range fields { + // The fields were read with ReadASN1Element so they still include + // their tag and length. Add them straight to the builder. + tbsCertificate.AddBytes(f) + } - // subjectPublicKeyInfo - tbsCertificate.AddBytes(subjectPublicKeyInfo) + // subjectPublicKeyInfo + tbsCertificate.AddBytes(subjectPublicKeyInfo) - // extensions - tbsCertificate.AddBytes(extensions) - }) - // signatureAlgorithm - certificate.AddBytes(proof.SigAlgEncoded()) - // signature - certificate.AddASN1BitString(inclusionProofBytes) + // extensions + tbsCertificate.AddBytes(extensions) }) return builder.Bytes() diff --git a/trees/entry/entry_test.go b/trees/entry/entry_test.go index 0c10f4a1273..a5d65edcb91 100644 --- a/trees/entry/entry_test.go +++ b/trees/entry/entry_test.go @@ -13,7 +13,8 @@ import ( "strings" "testing" - "golang.org/x/mod/sumdb/tlog" + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" "github.com/letsencrypt/boulder/trees/proof" ) @@ -80,7 +81,7 @@ AQUFBwMCMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUgPmX4p5B3cSQ2bkaNcye } } -func TestToMTC(t *testing.T) { +func TestToTBSCertificate(t *testing.T) { input := mustDecodeB64(t, testCertB64) spki := mustDecodeB64(t, testSPKIB64) @@ -89,24 +90,24 @@ func TestToMTC(t *testing.T) { t.Fatal(err) } - sig := proof.MTCProof{ - Start: 123, - End: 456, - InclusionProof: []tlog.Hash{ - {1, 2, 3, 4, 5, 6, 7, 8, 9}, - }, - } - - out, err := mtcle.ToMTC(123, spki, crypto.SHA256, sig) + out, err := mtcle.ToTBSCertificate(8675309, spki, crypto.SHA256) if err != nil { t.Fatal(err) } - // Rather than comparing against golden bytes, check the output - // structurally: it must be a well-formed X.509 certificate whose issuer, - // validity, subject, SPKI, and extensions match the original certificate, - // with the caller-provided serial and signature. - cert, err := x509.ParseCertificate(out) + // Turn it into something that will parse with crypto/x509 + var builder cryptobyte.Builder + builder.AddASN1(asn1.SEQUENCE, func(certificate *cryptobyte.Builder) { + // tbsCertificate + certificate.AddBytes(out) + // signatureAlgorithm + certificate.AddBytes(proof.SigAlgEncoded()) + // signature + certificate.AddASN1BitString(nil) + }) + certBytes := builder.BytesOrPanic() + + cert, err := x509.ParseCertificate(certBytes) if err != nil { t.Fatalf("parsing ToMTC() output: %s", err) } @@ -115,8 +116,8 @@ func TestToMTC(t *testing.T) { t.Fatal(err) } - if cert.SerialNumber.Cmp(big.NewInt(123)) != 0 { - t.Errorf("serialNumber: got %v, want 123", cert.SerialNumber) + if cert.SerialNumber.Cmp(big.NewInt(8675309)) != 0 { + t.Errorf("serialNumber: got %v, want 8675309", cert.SerialNumber) } if !bytes.Equal(cert.RawIssuer, orig.RawIssuer) { t.Errorf("issuer: got %x, want %x", cert.RawIssuer, orig.RawIssuer) @@ -135,22 +136,8 @@ func TestToMTC(t *testing.T) { t.Errorf("extensions: got %v, want %v", cert.Extensions, orig.Extensions) } - // The signature must be the marshaled MTCProof. - sigBytes, err := sig.Marshal() - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(cert.Signature, sigBytes) { - t.Errorf("signature: got %x, want %x", cert.Signature, sigBytes) - } - - // Both the TBSCertificate signature field and the Certificate - // signatureAlgorithm field must be the experimental id-alg-mtcProof - // AlgorithmIdentifier (1.3.6.1.4.1.44363.47.0) with parameters omitted. - // x509.ParseCertificate already checks that the two are equal. - sigAlg := proof.SigAlgEncoded() - if got := bytes.Count(out, sigAlg); got != 2 { - t.Errorf("id-alg-mtcProof AlgorithmIdentifier: found %d copies, want 2", got) + if !bytes.Contains(out, proof.SigAlgEncoded()) { + t.Errorf("output didn't contain id-alg-mtcProof") } } @@ -206,7 +193,7 @@ func TestToMTCMalformed(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := tc.mtcle.ToMTC(123, tc.spki, tc.hash, proof.MTCProof{}) + _, err := tc.mtcle.ToTBSCertificate(123, tc.spki, tc.hash) if err == nil { t.Errorf("ToMTC(): got nil err, want error") } @@ -214,7 +201,7 @@ func TestToMTCMalformed(t *testing.T) { } // serialNumber must be a positive integer (RFC 5280 4.1.2.2). - if _, err := valid.ToMTC(0, spki, crypto.SHA256, proof.MTCProof{}); err == nil { + if _, err := valid.ToTBSCertificate(0, spki, crypto.SHA256); err == nil { t.Errorf("ToMTC() with zero serial: got nil err, want error") } }