From 419138a68f37f785d684ccba6449c54feea315c7 Mon Sep 17 00:00:00 2001 From: Philippe Boneff Date: Fri, 26 Jun 2026 09:28:04 +0000 Subject: [PATCH 1/4] Retrieve stored entry on deduplication and validate against submitted entry When handling duplicate submissions, retrieve the full stored ctonly.Entry from log storage rather than only extracting the timestamp. Validate that IsPrecert, Certificate, and IssuerKeyHash match the submitted entry to guard against deduplication collisions or antispam driver errors. Once validated, reassign the submitted entry to the stored entry so that all inputs to SCT generation come coherently from storage. Optimize bundle extraction via ExtractEntryFromBundle by skipping over non-target tiles and ignoring extensions and fingerprints while preserving field documentation comments. TAG=agy CONV=50366255-7cb8-46d8-b354-53839f838955 --- internal/ct/ctlog.go | 2 +- internal/ct/handlers.go | 21 +++++++- internal/types/staticct/staticct.go | 67 +++++++++++++++--------- internal/types/staticct/staticct_test.go | 66 +++++++++++------------ storage/storage.go | 27 ++++------ 5 files changed, 106 insertions(+), 77 deletions(-) diff --git a/internal/ct/ctlog.go b/internal/ct/ctlog.go index 33e345326..7d88094fa 100644 --- a/internal/ct/ctlog.go +++ b/internal/ct/ctlog.go @@ -40,7 +40,7 @@ type Storage interface { // Add assigns an index to the provided Entry, stages the entry for integration, and returns a future for the assigned index. Add(context.Context, *ctonly.Entry) (tessera.IndexFuture, error) // DedupFuture fetches a duplicate tessera ctlog entry from the log and extracts its timestamp. - DedupFuture(context.Context, tessera.IndexFuture) (uint64, error) + DedupFuture(context.Context, tessera.IndexFuture) (*ctonly.Entry, error) // AddIssuerChain stores every the chain certificate in a content-addressable store under their sha256 hash. AddIssuerChain(context.Context, []*x509.Certificate) error } diff --git a/internal/ct/handlers.go b/internal/ct/handlers.go index 469775ac7..97e156f70 100644 --- a/internal/ct/handlers.go +++ b/internal/ct/handlers.go @@ -17,6 +17,7 @@ package ct import ( "bytes" "context" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" @@ -33,6 +34,7 @@ import ( "time" "github.com/transparency-dev/tessera" + "github.com/transparency-dev/tessera/ctonly" "github.com/transparency-dev/tesseract/internal/logger" "github.com/transparency-dev/tesseract/internal/otel" @@ -512,10 +514,14 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt w.Header().Add("Retry-After", strconv.Itoa(rand.IntN(5)+1)) // random retry within [1,6) seconds return http.StatusTooManyRequests, []attribute.KeyValue{duplicateKey.Bool(index.IsDup), tooManyRequestsReasonKey.String("rate_limit_dedup")}, errors.New(http.StatusText(http.StatusTooManyRequests)) } - entry.Timestamp, err = log.storage.DedupFuture(ctx, future) + dupEntry, err := log.storage.DedupFuture(ctx, future) if err != nil { return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("could not resolve duplicate: %v", err) } + if err := entryMatches(dupEntry, entry); err != nil { + return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("deduplicated entry in storage does not match submitted entry: %w", err) + } + entry = dupEntry } // Always use the returned leaf as the basis for an SCT. @@ -553,6 +559,19 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt return http.StatusOK, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, nil } +func entryMatches(stored, submitted *ctonly.Entry) error { + if stored.IsPrecert != submitted.IsPrecert { + return fmt.Errorf("IsPrecert mismatch: stored=%v, submitted=%v", stored.IsPrecert, submitted.IsPrecert) + } + if !bytes.Equal(stored.Certificate, submitted.Certificate) { + return fmt.Errorf("Certificate mismatch: stored(len=%d, sha256=%x) != submitted(len=%d, sha256=%x)", len(stored.Certificate), sha256.Sum256(stored.Certificate), len(submitted.Certificate), sha256.Sum256(submitted.Certificate)) + } + if !bytes.Equal(stored.IssuerKeyHash, submitted.IssuerKeyHash) { + return fmt.Errorf("IssuerKeyHash mismatch: stored=issuer/%x != submitted=issuer/%x", stored.IssuerKeyHash, submitted.IssuerKeyHash) + } + return nil +} + func addChain(ctx context.Context, opts *HandlerOptions, log *log, w http.ResponseWriter, r *http.Request) (int, []attribute.KeyValue, error) { ctx, span := tracer.Start(ctx, "tesseract.addChain") defer span.End() diff --git a/internal/types/staticct/staticct.go b/internal/types/staticct/staticct.go index b6689c7b7..ade4cc7b7 100644 --- a/internal/types/staticct/staticct.go +++ b/internal/types/staticct/staticct.go @@ -21,6 +21,7 @@ import ( "math" "github.com/transparency-dev/tessera/api/layout" + "github.com/transparency-dev/tessera/ctonly" "golang.org/x/crypto/cryptobyte" ) @@ -112,11 +113,11 @@ func (t *EntryBundle) UnmarshalText(raw []byte) error { return nil } -// ExtractTimestampFromBundle extracts the timestamp from the Nth entry in the provided serialised entry bundle. +// ExtractEntryFromBundle extracts the entry fields of the Nth entry from the provided serialised entry bundle. // -// This implementation attempts to avoid any unecessary allocation or parsing other than whatever is -// necessary to skip over uninteresting bytes to find the requested ExtractTimestampFromBundle. -func ExtractTimestampFromBundle(ebRaw []byte, N uint64) (uint64, error) { +// This implementation avoids unnecessary parsing and allocation by skipping over +// uninteresting bytes and ignoring extensions and fingerprints. +func ExtractEntryFromBundle(ebRaw []byte, N uint64) (*ctonly.Entry, error) { s := cryptobyte.String(ebRaw) i := uint64(0) @@ -124,47 +125,65 @@ func ExtractTimestampFromBundle(ebRaw []byte, N uint64) (uint64, error) { var timestamp uint64 var entryType uint16 if !s.ReadUint64(×tamp) || !s.ReadUint16(&entryType) || timestamp > math.MaxInt64 { - return 0, fmt.Errorf("invalid data tile when reading entry %d", i) - } - if i == N { - return timestamp, nil + return nil, fmt.Errorf("invalid data tile when reading entry %d", i) } var l32 uint32 var l16 uint16 switch entryType { case 0: // x509_entry - if - // entry - !s.ReadUint24(&l32) || !s.Skip(int(l32)) || - // extensions + if i == N { + var entry, extensions, fingerprints cryptobyte.String + if !s.ReadUint24LengthPrefixed((*cryptobyte.String)(&entry)) || + !s.ReadUint16LengthPrefixed(&extensions) || + !s.ReadUint16LengthPrefixed(&fingerprints) { + return nil, fmt.Errorf("invalid data tile x509_entry at index %d", i) + } + return &ctonly.Entry{ + Timestamp: timestamp, + IsPrecert: false, + Certificate: bytes.Clone(entry), + }, nil + } + if !s.ReadUint24(&l32) || !s.Skip(int(l32)) || !s.ReadUint16(&l16) || !s.Skip(int(l16)) || - // fingerprints !s.ReadUint16(&l16) || !s.Skip(int(l16)) { - return 0, fmt.Errorf("invalid data tile x509_entry when reading index %d", i) + return nil, fmt.Errorf("invalid data tile x509_entry when reading index %d", i) } case 1: // precert_entry - if - // issuer key hash - !s.Skip(32) || - // defangedCrt + if i == N { + issuerKeyHash := [32]byte{} + var defangedCrt, extensions, fingerprints cryptobyte.String + var entry []byte + if !s.CopyBytes(issuerKeyHash[:]) || + !s.ReadUint24LengthPrefixed(&defangedCrt) || + !s.ReadUint16LengthPrefixed(&extensions) || + !s.ReadUint24LengthPrefixed((*cryptobyte.String)(&entry)) || + !s.ReadUint16LengthPrefixed(&fingerprints) { + return nil, fmt.Errorf("invalid data tile precert_entry at index %d", i) + } + return &ctonly.Entry{ + Timestamp: timestamp, + IsPrecert: true, + Certificate: bytes.Clone(defangedCrt), + IssuerKeyHash: bytes.Clone(issuerKeyHash[:]), + }, nil + } + if !s.Skip(32) || !s.ReadUint24(&l32) || !s.Skip(int(l32)) || - // extensions !s.ReadUint16(&l16) || !s.Skip(int(l16)) || - // entry !s.ReadUint24(&l32) || !s.Skip(int(l32)) || - // fingerprints !s.ReadUint16(&l16) || !s.Skip(int(l16)) { - return 0, fmt.Errorf("invalid data tile precert_entry when reading index %d", i) + return nil, fmt.Errorf("invalid data tile precert_entry when reading index %d", i) } default: - return 0, fmt.Errorf("invalid data tile: unknown type %d", entryType) + return nil, fmt.Errorf("invalid data tile: unknown type %d", entryType) } i++ } - return 0, fmt.Errorf("requested entry index %d, but found only %d entries", N, i) + return nil, fmt.Errorf("requested entry index %d, but found only %d entries", N, i) } // parseCTExtensions parses CTExtensions into an index. diff --git a/internal/types/staticct/staticct_test.go b/internal/types/staticct/staticct_test.go index f3eb88b1c..a0747c000 100644 --- a/internal/types/staticct/staticct_test.go +++ b/internal/types/staticct/staticct_test.go @@ -15,69 +15,67 @@ package staticct import ( + "bytes" "fmt" "math/rand/v2" "testing" + "github.com/transparency-dev/tessera/ctonly" "github.com/transparency-dev/tesseract/testdata" ) -// oldExtractTimestampFromBundle extracts the timestamp from the Nth entry in the provided serialised entry bundle. -// -// This is the original naive implementation which works by parsing and extrating everything from the bundle, -// and pulling out only the 64bit timestamp we're actually interested in. -// -// This is only being kept around for comparison with the faster implementation in ExtractTimestampFromBundle, -// this func & the benchmark which references it can be removed shortly. -func oldExtractTimestampFromBundle(ebRaw []byte, eIdx uint64) (uint64, error) { +func oldExtractEntryFromBundle(ebRaw []byte, eIdx uint64) (*ctonly.Entry, error) { eb := EntryBundle{} if err := eb.UnmarshalText(ebRaw); err != nil { - return 0, fmt.Errorf("failed to unmarshal entry bundle: %v", err) + return nil, fmt.Errorf("failed to unmarshal entry bundle: %v", err) } if l := uint64(len(eb.Entries)); l <= eIdx { - return 0, fmt.Errorf("entry bundle has only %d entries, but wanted at least %d", l, eIdx) + return nil, fmt.Errorf("entry bundle has only %d entries, but wanted at least %d", l, eIdx) } e := Entry{} - t, err := UnmarshalTimestamp([]byte(eb.Entries[eIdx])) - if err != nil { - return 0, fmt.Errorf("failed to extract timestamp from entry %d in entry bundle: %v", eIdx, e) - } - return t, nil -} - -func BenchmarkOldExtractTimestampFromBundle(b *testing.B) { - for b.Loop() { - i := rand.Uint64N(256) - _, err := oldExtractTimestampFromBundle(testdata.ExampleFullTile, i) - if err != nil { - b.Fatalf("timestampOld: %v", err) - } + if err := e.UnmarshalText([]byte(eb.Entries[eIdx])); err != nil { + return nil, fmt.Errorf("failed to unmarshal entry %d in entry bundle: %v", eIdx, err) } + return &ctonly.Entry{ + Timestamp: e.Timestamp, + IsPrecert: e.IsPrecert, + Certificate: e.Certificate, + IssuerKeyHash: e.IssuerKeyHash, + }, nil } -func BenchmarkExtractTimestampFromBundle(b *testing.B) { +func BenchmarkExtractEntryFromBundle(b *testing.B) { for b.Loop() { i := rand.Uint64N(256) - _, err := ExtractTimestampFromBundle(testdata.ExampleFullTile, i) + _, err := ExtractEntryFromBundle(testdata.ExampleFullTile, i) if err != nil { - b.Fatalf("timestamp: %v", err) + b.Fatalf("ExtractEntryFromBundle: %v", err) } } } -func TestExtractTimestampFromBundle(t *testing.T) { +func TestExtractEntryFromBundle(t *testing.T) { for i := uint64(0); i < 256; i++ { - ts1, err := oldExtractTimestampFromBundle(testdata.ExampleFullTile, i) + expected, err := oldExtractEntryFromBundle(testdata.ExampleFullTile, i) if err != nil { - t.Fatalf("timestampOld: %v", err) + t.Fatalf("oldExtractEntryFromBundle: %v", err) } - ts2, err := ExtractTimestampFromBundle(testdata.ExampleFullTile, i) + entry, err := ExtractEntryFromBundle(testdata.ExampleFullTile, i) if err != nil { - t.Fatalf("timestampOld: %v", err) + t.Fatalf("ExtractEntryFromBundle: %v", err) + } + if entry.Timestamp != expected.Timestamp { + t.Errorf("%d: timestamp mismatch", i) + } + if entry.IsPrecert != expected.IsPrecert { + t.Errorf("%d: IsPrecert mismatch", i) + } + if !bytes.Equal(entry.Certificate, expected.Certificate) { + t.Errorf("%d: Certificate mismatch", i) } - if ts1 != ts2 { - t.Errorf("%d: timestampOld %d != timestamp %d", i, ts1, ts2) + if !bytes.Equal(entry.IssuerKeyHash, expected.IssuerKeyHash) { + t.Errorf("%d: IssuerKeyHash mismatch", i) } } } diff --git a/storage/storage.go b/storage/storage.go index 8a8cf3f2b..1b1025349 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -106,37 +106,37 @@ func NewCTStorage(ctx context.Context, opts *CTStorageOptions) (*CTStorage, erro // extracts the timestamp from it. // // TODO(phbnf): cache timestamps (or more) to avoid reparsing the entire leaf bundle -func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (uint64, error) { - return trace1(ctx, "tesseract.storage.DedupFuture", func(ctx context.Context) (uint64, error) { +func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (*ctonly.Entry, error) { + return trace1(ctx, "tesseract.storage.DedupFuture", func(ctx context.Context) (*ctonly.Entry, error) { idx, cpRaw, err := cts.awaiter.Await(ctx, f) if err != nil { - return 0, fmt.Errorf("error waiting for Tessera index future and its integration: %w", err) + return nil, fmt.Errorf("error waiting for Tessera index future and its integration: %w", err) } // A https://c2sp.org/static-ct-api logsize is on the second line l := bytes.SplitN(cpRaw, []byte("\n"), 3) if len(l) < 2 { - return 0, errors.New("invalid checkpoint - no size") + return nil, errors.New("invalid checkpoint - no size") } ckptSize, err := strconv.ParseUint(string(l[1]), 10, 64) if err != nil { - return 0, fmt.Errorf("invalid checkpoint - can't extract size: %v", err) + return nil, fmt.Errorf("invalid checkpoint - can't extract size: %v", err) } eBIdx := idx.Index / layout.EntryBundleWidth eBRaw, err := cts.reader.ReadEntryBundle(ctx, eBIdx, layout.PartialTileSize(0, eBIdx, ckptSize)) if err != nil { if errors.Is(err, os.ErrNotExist) { - return 0, fmt.Errorf("leaf bundle at index %d not found: %v", eBIdx, err) + return nil, fmt.Errorf("leaf bundle at index %d not found: %v", eBIdx, err) } - return 0, fmt.Errorf("failed to fetch entry bundle at index %d: %v", eBIdx, err) + return nil, fmt.Errorf("failed to fetch entry bundle at index %d: %v", eBIdx, err) } eIdx := idx.Index % layout.EntryBundleWidth - t, err := timestamp(eBRaw, eIdx) + entry, err := staticct.ExtractEntryFromBundle(eBRaw, eIdx) if err != nil { - return 0, fmt.Errorf("failed to extract timestamp of entry %d in bundle index %d: %v", eIdx, eBIdx, err) + return nil, fmt.Errorf("failed to extract entry %d in bundle index %d: %v", eIdx, eBIdx, err) } - return t, nil + return entry, nil }) } @@ -210,10 +210,3 @@ func cachedStoreIssuers(s IssuerStorage) func(context.Context, []KV) error { } } -// timestamp extracts the timestamp from the Nth entry in the provided serialised entry bundle. -// -// This implementation attempts to avoid any unecessary allocation or parsing other than whatever is -// necessary to skip over uninteresting bytes to find the requested timestamp. -func timestamp(ebRaw []byte, N uint64) (uint64, error) { - return staticct.ExtractTimestampFromBundle(ebRaw, N) -} From abfa3e7c6d522db8dba0272bfc7fe211a46c1aa5 Mon Sep 17 00:00:00 2001 From: Philippe Boneff Date: Fri, 26 Jun 2026 09:28:26 +0000 Subject: [PATCH 2/4] Refactor SCT generation to derive CertificateTimestamp directly from leaves Update SCT generation so that signSCT accepts rfc6962.CertificateTimestamp directly rather than relying on intermediate data structures. For non-duplicate submissions and signing unit tests, derive the SCT signing input by generating the MerkleTreeLeaf via Tessera (entry.MerkleTreeLeaf) and unmarshalling it using ExtractCertificateTimestampFromLeaf. This guarantees 100% coherence between the data Tessera integrates into the Merkle tree and the data signed in the SCT while eliminating duplicated extension marshalling logic. TAG=agy CONV=50366255-7cb8-46d8-b354-53839f838955 --- internal/ct/ctlog.go | 6 +- internal/ct/handlers.go | 57 ++++++---- internal/ct/signatures.go | 39 +------ internal/ct/signatures_test.go | 76 +++---------- internal/types/staticct/staticct.go | 135 +++++++++++++++-------- internal/types/staticct/staticct_test.go | 73 +++++------- storage/storage.go | 25 +++-- 7 files changed, 193 insertions(+), 218 deletions(-) diff --git a/internal/ct/ctlog.go b/internal/ct/ctlog.go index 7d88094fa..4497a9b05 100644 --- a/internal/ct/ctlog.go +++ b/internal/ct/ctlog.go @@ -33,14 +33,14 @@ type log struct { } // signSCT builds an SCT for a leaf. -type signSCT func(leaf *rfc6962.MerkleTreeLeaf) (*rfc6962.SignedCertificateTimestamp, error) +type signSCT func(sctInput *rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) // Storage provides functions to store certificates in a static-ct-api log. type Storage interface { // Add assigns an index to the provided Entry, stages the entry for integration, and returns a future for the assigned index. Add(context.Context, *ctonly.Entry) (tessera.IndexFuture, error) - // DedupFuture fetches a duplicate tessera ctlog entry from the log and extracts its timestamp. - DedupFuture(context.Context, tessera.IndexFuture) (*ctonly.Entry, error) + // DedupFuture fetches the SCT input fields for a duplicate entry from the log. + DedupFuture(context.Context, tessera.IndexFuture) (*rfc6962.CertificateTimestamp, error) // AddIssuerChain stores every the chain certificate in a content-addressable store under their sha256 hash. AddIssuerChain(context.Context, []*x509.Certificate) error } diff --git a/internal/ct/handlers.go b/internal/ct/handlers.go index 97e156f70..b86f11dba 100644 --- a/internal/ct/handlers.go +++ b/internal/ct/handlers.go @@ -41,6 +41,7 @@ import ( "github.com/transparency-dev/tesseract/internal/types/rfc6962" "github.com/transparency-dev/tesseract/internal/types/tls" "github.com/transparency-dev/tesseract/internal/x509util" + "github.com/transparency-dev/tesseract/internal/types/staticct" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/metric" @@ -509,33 +510,29 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt return http.StatusInternalServerError, nil, fmt.Errorf("couldn't resolve tessera future: %v", err) } + var sctInput *rfc6962.CertificateTimestamp if index.IsDup { if ok := opts.RateLimits.AcceptDedup(ctx); !ok { w.Header().Add("Retry-After", strconv.Itoa(rand.IntN(5)+1)) // random retry within [1,6) seconds return http.StatusTooManyRequests, []attribute.KeyValue{duplicateKey.Bool(index.IsDup), tooManyRequestsReasonKey.String("rate_limit_dedup")}, errors.New(http.StatusText(http.StatusTooManyRequests)) } - dupEntry, err := log.storage.DedupFuture(ctx, future) + var err error + sctInput, err = log.storage.DedupFuture(ctx, future) if err != nil { return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("could not resolve duplicate: %v", err) } - if err := entryMatches(dupEntry, entry); err != nil { + if err := entrySCTsMatch(sctInput, entry, index.Index); err != nil { return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("deduplicated entry in storage does not match submitted entry: %w", err) } - entry = dupEntry - } - - // Always use the returned leaf as the basis for an SCT. - var loggedLeaf rfc6962.MerkleTreeLeaf - leafValue := entry.MerkleTreeLeaf(index.Index) - if rest, err := tls.Unmarshal(leafValue, &loggedLeaf); err != nil { - return http.StatusInternalServerError, nil, fmt.Errorf("failed to reconstruct MerkleTreeLeaf: %s", err) - } else if len(rest) > 0 { - return http.StatusInternalServerError, nil, fmt.Errorf("extra data (%d bytes) on reconstructing MerkleTreeLeaf", len(rest)) + } else { + var err error + leafBytes := entry.MerkleTreeLeaf(index.Index) + sctInput, err = staticct.ExtractCertificateTimestampFromLeaf(leafBytes) + if err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to extract SCT input from leaf: %v", err) + } } - - // As the Log server has definitely got the Merkle tree leaf, we can - // generate an SCT and respond with it. - sct, err := log.signSCT(&loggedLeaf) + sct, err := log.signSCT(sctInput) if err != nil { return http.StatusInternalServerError, nil, fmt.Errorf("failed to generate SCT: %s", err) } @@ -559,15 +556,29 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt return http.StatusOK, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, nil } -func entryMatches(stored, submitted *ctonly.Entry) error { - if stored.IsPrecert != submitted.IsPrecert { - return fmt.Errorf("IsPrecert mismatch: stored=%v, submitted=%v", stored.IsPrecert, submitted.IsPrecert) +// entrySCTsMatch checks that sctInput fields match with an entry. +// It checks for all the sctInput fields, except for the timestamp which might +// not be set in the entry yet. +func entrySCTsMatch(sct *rfc6962.CertificateTimestamp, e *ctonly.Entry, idx uint64) error { + extractedIdx, err := staticct.ParseCTExtensionsBytes(sct.Extensions) + if err != nil || extractedIdx != idx { + return fmt.Errorf("index mismatch: stored=%d, expected=%d", extractedIdx, idx) + } + isPrecert := sct.EntryType == rfc6962.PrecertLogEntryType + if isPrecert != e.IsPrecert { + return fmt.Errorf("isPrecert mismatch: sct.IsPrecert=%v, e.IsPrecert=%v", isPrecert, e.IsPrecert) } - if !bytes.Equal(stored.Certificate, submitted.Certificate) { - return fmt.Errorf("Certificate mismatch: stored(len=%d, sha256=%x) != submitted(len=%d, sha256=%x)", len(stored.Certificate), sha256.Sum256(stored.Certificate), len(submitted.Certificate), sha256.Sum256(submitted.Certificate)) + var sctCert []byte + if isPrecert { + sctCert = sct.PrecertEntry.TBSCertificate + if !bytes.Equal(sct.PrecertEntry.IssuerKeyHash[:], e.IssuerKeyHash) { + return fmt.Errorf("issuerKeyHash mismatch: stored=issuer/%x != submitted=issuer/%x", sct.PrecertEntry.IssuerKeyHash[:], e.IssuerKeyHash) + } + } else { + sctCert = sct.X509Entry.Data } - if !bytes.Equal(stored.IssuerKeyHash, submitted.IssuerKeyHash) { - return fmt.Errorf("IssuerKeyHash mismatch: stored=issuer/%x != submitted=issuer/%x", stored.IssuerKeyHash, submitted.IssuerKeyHash) + if !bytes.Equal(sctCert, e.Certificate) { + return fmt.Errorf("certificate mismatch: stored(len=%d, sha256=%x) != submitted(len=%d, sha256=%x)", len(sctCert), sha256.Sum256(sctCert), len(e.Certificate), sha256.Sum256(e.Certificate)) } return nil } diff --git a/internal/ct/signatures.go b/internal/ct/signatures.go index 16d43b36e..3ad646415 100644 --- a/internal/ct/signatures.go +++ b/internal/ct/signatures.go @@ -35,43 +35,8 @@ type sctSigner struct { signer crypto.Signer } -// serializeSCTSignatureInput serializes the passed in sct and log entry into -// the correct format for signing. -func serializeSCTSignatureInput(sct rfc6962.SignedCertificateTimestamp, entry rfc6962.LogEntry) ([]byte, error) { - switch sct.SCTVersion { - case rfc6962.V1: - input := rfc6962.CertificateTimestamp{ - SCTVersion: sct.SCTVersion, - SignatureType: rfc6962.CertificateTimestampSignatureType, - Timestamp: sct.Timestamp, - EntryType: entry.Leaf.TimestampedEntry.EntryType, - Extensions: sct.Extensions, - } - switch entry.Leaf.TimestampedEntry.EntryType { - case rfc6962.X509LogEntryType: - input.X509Entry = entry.Leaf.TimestampedEntry.X509Entry - case rfc6962.PrecertLogEntryType: - input.PrecertEntry = &rfc6962.PreCert{ - IssuerKeyHash: entry.Leaf.TimestampedEntry.PrecertEntry.IssuerKeyHash, - TBSCertificate: entry.Leaf.TimestampedEntry.PrecertEntry.TBSCertificate, - } - default: - return nil, fmt.Errorf("unsupported entry type %s", entry.Leaf.TimestampedEntry.EntryType) - } - return tls.Marshal(input) - default: - return nil, fmt.Errorf("unknown SCT version %d", sct.SCTVersion) - } -} - -func (sctSigner *sctSigner) Sign(leaf *rfc6962.MerkleTreeLeaf) (*rfc6962.SignedCertificateTimestamp, error) { - // Serialize SCT signature input to get the bytes that need to be signed - sctInput := rfc6962.SignedCertificateTimestamp{ - SCTVersion: rfc6962.V1, - Timestamp: leaf.TimestampedEntry.Timestamp, - Extensions: leaf.TimestampedEntry.Extensions, - } - data, err := serializeSCTSignatureInput(sctInput, rfc6962.LogEntry{Leaf: *leaf}) +func (sctSigner *sctSigner) Sign(sctInput *rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) { + data, err := tls.Marshal(*sctInput) if err != nil { return nil, fmt.Errorf("failed to serialize SCT data: %v", err) } diff --git a/internal/ct/signatures_test.go b/internal/ct/signatures_test.go index bbc211390..0fa56bbf7 100644 --- a/internal/ct/signatures_test.go +++ b/internal/ct/signatures_test.go @@ -29,6 +29,7 @@ import ( "github.com/transparency-dev/tesseract/internal/types/rfc6962" "github.com/transparency-dev/tesseract/internal/types/tls" "github.com/transparency-dev/tesseract/internal/x509util" + "github.com/transparency-dev/tesseract/internal/types/staticct" ) var ( @@ -41,9 +42,7 @@ var ( ) const ( - defaultSCTLogIDString string = "iamapublickeyshatwofivesixdigest" defaultSCTTimestamp uint64 = 1234 - defaultSCTSignatureString string = "\x04\x03\x00\x09signature" defaultCertifictateString string = "certificate" defaultPrecertIssuerHashString string = "iamapublickeyshatwofivesixdigest" defaultPrecertTBSString string = "tbs" @@ -100,28 +99,6 @@ const ( "696d757374626565786163746c7974686972747974776f62797465736c6f6e67" ) -func defaultSCTLogID() rfc6962.LogID { - var id rfc6962.LogID - copy(id.KeyID[:], defaultSCTLogIDString) - return id -} - -func defaultSCTSignature() rfc6962.DigitallySigned { - var ds rfc6962.DigitallySigned - if _, err := tls.Unmarshal([]byte(defaultSCTSignatureString), &ds); err != nil { - panic(err) - } - return ds -} - -func defaultSCT() rfc6962.SignedCertificateTimestamp { - return rfc6962.SignedCertificateTimestamp{ - SCTVersion: rfc6962.V1, - LogID: defaultSCTLogID(), - Timestamp: defaultSCTTimestamp, - Extensions: []byte{}, - Signature: defaultSCTSignature()} -} func defaultCertificate() []byte { return []byte(defaultCertifictateString) @@ -136,19 +113,8 @@ func defaultCertificateSCTSignatureInput(t *testing.T) []byte { return r } -func defaultCertificateLogEntry() rfc6962.LogEntry { - return rfc6962.LogEntry{ - Index: 1, - Leaf: rfc6962.MerkleTreeLeaf{ - Version: rfc6962.V1, - LeafType: rfc6962.TimestampedEntryLeafType, - TimestampedEntry: &rfc6962.TimestampedEntry{ - Timestamp: defaultSCTTimestamp, - EntryType: rfc6962.X509LogEntryType, - X509Entry: &rfc6962.ASN1Cert{Data: defaultCertificate()}, - }, - }, - } +func defaultCertificateSCTInput() *rfc6962.CertificateTimestamp { + return staticct.NewCertificateTimestamp([]byte{}, defaultSCTTimestamp, false, defaultCertificate(), [32]byte{}) } func defaultPrecertSCTSignatureInput(t *testing.T) []byte { @@ -170,22 +136,9 @@ func defaultPrecertIssuerHash() [32]byte { return b } -func defaultPrecertLogEntry() rfc6962.LogEntry { - return rfc6962.LogEntry{ - Index: 1, - Leaf: rfc6962.MerkleTreeLeaf{ - Version: rfc6962.V1, - LeafType: rfc6962.TimestampedEntryLeafType, - TimestampedEntry: &rfc6962.TimestampedEntry{ - Timestamp: defaultSCTTimestamp, - EntryType: rfc6962.PrecertLogEntryType, - PrecertEntry: &rfc6962.PreCert{ - IssuerKeyHash: defaultPrecertIssuerHash(), - TBSCertificate: defaultPrecertTBS(), - }, - }, - }, - } +func defaultPrecertSCTInput() *rfc6962.CertificateTimestamp { + ikh := defaultPrecertIssuerHash() + return staticct.NewCertificateTimestamp([]byte{}, defaultSCTTimestamp, true, defaultPrecertTBS(), ikh) } func defaultSTH() rfc6962.SignedTreeHead { @@ -214,7 +167,7 @@ func mustDehex(t *testing.T, h string) []byte { } func TestSerializeV1SCTSignatureInputForCertificateKAT(t *testing.T) { - serialized, err := serializeSCTSignatureInput(defaultSCT(), defaultCertificateLogEntry()) + serialized, err := tls.Marshal(*defaultCertificateSCTInput()) if err != nil { t.Fatalf("Failed to serialize SCT for signing: %v", err) } @@ -224,7 +177,7 @@ func TestSerializeV1SCTSignatureInputForCertificateKAT(t *testing.T) { } func TestSerializeV1SCTSignatureInputForPrecertKAT(t *testing.T) { - serialized, err := serializeSCTSignatureInput(defaultSCT(), defaultPrecertLogEntry()) + serialized, err := tls.Marshal(*defaultPrecertSCTInput()) if err != nil { t.Fatalf("Failed to serialize SCT for signing: %v", err) } @@ -268,7 +221,11 @@ func TestBuildV1MerkleTreeLeafForCert(t *testing.T) { } else if len(rest) > 0 { t.Fatalf("extra data (%d bytes) on reconstructing MerkleTreeLeaf", len(rest)) } - got, err := sctSigner.Sign(&leaf) + input, err := staticct.ExtractCertificateTimestampFromLeaf(leafValue) + if err != nil { + t.Fatalf("ExtractCertificateTimestampFromLeaf()=nil,%v; want _,nil", err) + } + got, err := sctSigner.Sign(input) if err != nil { t.Fatalf("buildV1SCT()=nil,%v; want _,nil", err) } @@ -333,8 +290,11 @@ func TestSignV1SCTForPrecertificate(t *testing.T) { } else if len(rest) > 0 { t.Fatalf("extra data (%d bytes) on reconstructing MerkleTreeLeaf", len(rest)) } - - got, err := sctSigner.Sign(&leaf) + input, err := staticct.ExtractCertificateTimestampFromLeaf(leafValue) + if err != nil { + t.Fatalf("ExtractCertificateTimestampFromLeaf()=nil,%v; want _,nil", err) + } + got, err := sctSigner.Sign(input) if err != nil { t.Fatalf("buildV1SCT()=nil,%v; want _,nil", err) } diff --git a/internal/types/staticct/staticct.go b/internal/types/staticct/staticct.go index ade4cc7b7..bd828ac6a 100644 --- a/internal/types/staticct/staticct.go +++ b/internal/types/staticct/staticct.go @@ -17,11 +17,13 @@ package staticct import ( "bytes" "encoding/base64" + "errors" "fmt" "math" "github.com/transparency-dev/tessera/api/layout" - "github.com/transparency-dev/tessera/ctonly" + "github.com/transparency-dev/tesseract/internal/types/rfc6962" + "github.com/transparency-dev/tesseract/internal/types/tls" "golang.org/x/crypto/cryptobyte" ) @@ -113,11 +115,55 @@ func (t *EntryBundle) UnmarshalText(raw []byte) error { return nil } -// ExtractEntryFromBundle extracts the entry fields of the Nth entry from the provided serialised entry bundle. +// NewCertificateTimestamp creates an rfc6962.CertificateTimestamp with the provided RFC6962 CTExtensions byte slice. +func NewCertificateTimestamp(ext []byte, timestamp uint64, isPrecert bool, cert []byte, ikh [32]byte) *rfc6962.CertificateTimestamp { + ct := &rfc6962.CertificateTimestamp{ + SCTVersion: rfc6962.V1, + SignatureType: rfc6962.CertificateTimestampSignatureType, + Timestamp: timestamp, + Extensions: ext, + } + if isPrecert { + ct.EntryType = rfc6962.PrecertLogEntryType + ct.PrecertEntry = &rfc6962.PreCert{ + IssuerKeyHash: ikh, + TBSCertificate: cert, + } + } else { + ct.EntryType = rfc6962.X509LogEntryType + ct.X509Entry = &rfc6962.ASN1Cert{Data: cert} + } + return ct +} + +// ExtractCertificateTimestampFromLeaf parses a TLS-encoded MerkleTreeLeaf byte slice +// and returns the corresponding CertificateTimestamp. +func ExtractCertificateTimestampFromLeaf(leafBytes []byte) (*rfc6962.CertificateTimestamp, error) { + var leaf rfc6962.MerkleTreeLeaf + if rest, err := tls.Unmarshal(leafBytes, &leaf); err != nil { + return nil, fmt.Errorf("failed to unmarshal MerkleTreeLeaf: %w", err) + } else if len(rest) > 0 { + return nil, fmt.Errorf("extra data (%d bytes) after MerkleTreeLeaf", len(rest)) + } + if leaf.TimestampedEntry == nil { + return nil, errors.New("nil TimestampedEntry in MerkleTreeLeaf") + } + return &rfc6962.CertificateTimestamp{ + SCTVersion: rfc6962.V1, + SignatureType: rfc6962.CertificateTimestampSignatureType, + Timestamp: leaf.TimestampedEntry.Timestamp, + EntryType: leaf.TimestampedEntry.EntryType, + X509Entry: leaf.TimestampedEntry.X509Entry, + PrecertEntry: leaf.TimestampedEntry.PrecertEntry, + Extensions: leaf.TimestampedEntry.Extensions, + }, nil +} + +// ExtractSCTInputFromBundle extracts the SCT input fields of the Nth entry from the provided serialised entry bundle. // // This implementation avoids unnecessary parsing and allocation by skipping over // uninteresting bytes and ignoring extensions and fingerprints. -func ExtractEntryFromBundle(ebRaw []byte, N uint64) (*ctonly.Entry, error) { +func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTimestamp, error) { s := cryptobyte.String(ebRaw) i := uint64(0) @@ -133,20 +179,25 @@ func ExtractEntryFromBundle(ebRaw []byte, N uint64) (*ctonly.Entry, error) { switch entryType { case 0: // x509_entry if i == N { - var entry, extensions, fingerprints cryptobyte.String - if !s.ReadUint24LengthPrefixed((*cryptobyte.String)(&entry)) || + var entry []byte + var extensions, fingerprints cryptobyte.String + if + // entry + !s.ReadUint24LengthPrefixed((*cryptobyte.String)(&entry)) || + // extensions !s.ReadUint16LengthPrefixed(&extensions) || + // fingerprints !s.ReadUint16LengthPrefixed(&fingerprints) { return nil, fmt.Errorf("invalid data tile x509_entry at index %d", i) } - return &ctonly.Entry{ - Timestamp: timestamp, - IsPrecert: false, - Certificate: bytes.Clone(entry), - }, nil + return NewCertificateTimestamp([]byte(extensions), timestamp, false, bytes.Clone(entry), [32]byte{}), nil } - if !s.ReadUint24(&l32) || !s.Skip(int(l32)) || + if + // entry + !s.ReadUint24(&l32) || !s.Skip(int(l32)) || + // extensions !s.ReadUint16(&l16) || !s.Skip(int(l16)) || + // fingerprints !s.ReadUint16(&l16) || !s.Skip(int(l16)) { return nil, fmt.Errorf("invalid data tile x509_entry when reading index %d", i) } @@ -156,24 +207,31 @@ func ExtractEntryFromBundle(ebRaw []byte, N uint64) (*ctonly.Entry, error) { issuerKeyHash := [32]byte{} var defangedCrt, extensions, fingerprints cryptobyte.String var entry []byte - if !s.CopyBytes(issuerKeyHash[:]) || + if + // issuer key hash + !s.CopyBytes(issuerKeyHash[:]) || + // defangedCrt !s.ReadUint24LengthPrefixed(&defangedCrt) || + // extensions !s.ReadUint16LengthPrefixed(&extensions) || + // entry !s.ReadUint24LengthPrefixed((*cryptobyte.String)(&entry)) || + // fingerprints !s.ReadUint16LengthPrefixed(&fingerprints) { return nil, fmt.Errorf("invalid data tile precert_entry at index %d", i) } - return &ctonly.Entry{ - Timestamp: timestamp, - IsPrecert: true, - Certificate: bytes.Clone(defangedCrt), - IssuerKeyHash: bytes.Clone(issuerKeyHash[:]), - }, nil + return NewCertificateTimestamp([]byte(extensions), timestamp, true, bytes.Clone(defangedCrt), issuerKeyHash), nil } - if !s.Skip(32) || + if + // issuer key hash + !s.Skip(32) || + // defangedCrt !s.ReadUint24(&l32) || !s.Skip(int(l32)) || + // extensions !s.ReadUint16(&l16) || !s.Skip(int(l16)) || + // entry !s.ReadUint24(&l32) || !s.Skip(int(l32)) || + // fingerprints !s.ReadUint16(&l16) || !s.Skip(int(l16)) { return nil, fmt.Errorf("invalid data tile precert_entry when reading index %d", i) } @@ -186,14 +244,9 @@ func ExtractEntryFromBundle(ebRaw []byte, N uint64) (*ctonly.Entry, error) { return nil, fmt.Errorf("requested entry index %d, but found only %d entries", N, i) } -// parseCTExtensions parses CTExtensions into an index. -// Code is inspired by https://github.com/FiloSottile/sunlight/blob/main/tile.go. -func ParseCTExtensions(ext string) (uint64, error) { - extensionBytes, err := base64.StdEncoding.DecodeString(ext) - if err != nil { - return 0, fmt.Errorf("can't decode extensions: %v", err) - } - extensions := cryptobyte.String(extensionBytes) +// ParseCTExtensionsBytes parses binary CTExtensions into an index. +func ParseCTExtensionsBytes(ext []byte) (uint64, error) { + extensions := cryptobyte.String(ext) var extensionType uint8 var extensionData cryptobyte.String var leafIdx uint64 @@ -211,11 +264,21 @@ func ParseCTExtensions(ext string) (uint64, error) { } if !extensionData.Empty() || !extensions.Empty() { - return 0, fmt.Errorf("invalid SCT extension data: %v", ext) + return 0, fmt.Errorf("invalid SCT extension data: %x", ext) } return leafIdx, nil } +// ParseCTExtensions parses base64-encoded CTExtensions into an index. +// Code is inspired by https://github.com/FiloSottile/sunlight/blob/main/tile.go. +func ParseCTExtensions(ext string) (uint64, error) { + extensionBytes, err := base64.StdEncoding.DecodeString(ext) + if err != nil { + return 0, fmt.Errorf("can't decode extensions: %v", err) + } + return ParseCTExtensionsBytes(extensionBytes) +} + // readUint40 decodes a big-endian, 40-bit value into out and advances over it. // It reports whether the read was successful. // Code is copied from https://github.com/FiloSottile/sunlight/blob/main/extensions.go. @@ -245,20 +308,6 @@ type Entry struct { LeafIndex uint64 } -// UnmarshalText implements encoding/TextUnmarshaler and reads EntryBundles -// which are encoded using the Static CT API spec. -func UnmarshalTimestamp(raw []byte) (uint64, error) { - s := cryptobyte.String(raw) - var t uint64 - - if !s.ReadUint64(&t) { - return 0, fmt.Errorf("invalid data tile: timestamp can't be extracted") - } - if t > math.MaxInt64 { - return 0, fmt.Errorf("invalid data tile: timestamp %d > math.MaxInt64", t) - } - return t, nil -} // UnmarshalText implements encoding/TextUnmarshaler and reads EntryBundles // which are encoded using the Static CT API spec. @@ -332,7 +381,7 @@ func (t *Entry) UnmarshalText(raw []byte) error { } var err error - t.LeafIndex, err = ParseCTExtensions(base64.StdEncoding.EncodeToString([]byte(t.RawExtensions))) + t.LeafIndex, err = ParseCTExtensionsBytes([]byte(t.RawExtensions)) if err != nil { return fmt.Errorf("can't parse extensions: %v", err) } diff --git a/internal/types/staticct/staticct_test.go b/internal/types/staticct/staticct_test.go index a0747c000..16be54158 100644 --- a/internal/types/staticct/staticct_test.go +++ b/internal/types/staticct/staticct_test.go @@ -13,69 +13,50 @@ // limitations under the License. package staticct - import ( "bytes" - "fmt" - "math/rand/v2" "testing" - "github.com/transparency-dev/tessera/ctonly" + "github.com/transparency-dev/tesseract/internal/types/rfc6962" "github.com/transparency-dev/tesseract/testdata" ) -func oldExtractEntryFromBundle(ebRaw []byte, eIdx uint64) (*ctonly.Entry, error) { +func TestExtractSCTInputFromBundle(t *testing.T) { eb := EntryBundle{} - if err := eb.UnmarshalText(ebRaw); err != nil { - return nil, fmt.Errorf("failed to unmarshal entry bundle: %v", err) - } - - if l := uint64(len(eb.Entries)); l <= eIdx { - return nil, fmt.Errorf("entry bundle has only %d entries, but wanted at least %d", l, eIdx) + if err := eb.UnmarshalText(testdata.ExampleFullTile); err != nil { + t.Fatalf("failed to unmarshal full tile: %v", err) } - e := Entry{} - if err := e.UnmarshalText([]byte(eb.Entries[eIdx])); err != nil { - return nil, fmt.Errorf("failed to unmarshal entry %d in entry bundle: %v", eIdx, err) - } - return &ctonly.Entry{ - Timestamp: e.Timestamp, - IsPrecert: e.IsPrecert, - Certificate: e.Certificate, - IssuerKeyHash: e.IssuerKeyHash, - }, nil -} - -func BenchmarkExtractEntryFromBundle(b *testing.B) { - for b.Loop() { - i := rand.Uint64N(256) - _, err := ExtractEntryFromBundle(testdata.ExampleFullTile, i) + for i := uint64(0); i < uint64(len(eb.Entries)); i++ { + entry, err := ExtractSCTInputFromBundle(testdata.ExampleFullTile, i) if err != nil { - b.Fatalf("ExtractEntryFromBundle: %v", err) + t.Fatalf("ExtractSCTInputFromBundle(%d): %v", i, err) } - } -} - -func TestExtractEntryFromBundle(t *testing.T) { - for i := uint64(0); i < 256; i++ { - expected, err := oldExtractEntryFromBundle(testdata.ExampleFullTile, i) - if err != nil { - t.Fatalf("oldExtractEntryFromBundle: %v", err) + expected := Entry{} + if err := expected.UnmarshalText(eb.Entries[i]); err != nil { + t.Fatalf("UnmarshalText(%d): %v", i, err) } - entry, err := ExtractEntryFromBundle(testdata.ExampleFullTile, i) - if err != nil { - t.Fatalf("ExtractEntryFromBundle: %v", err) + extractedIdx, err := ParseCTExtensionsBytes(entry.Extensions) + if err != nil || extractedIdx != expected.LeafIndex { + t.Errorf("%d: extracted index %d != expected %d", i, extractedIdx, expected.LeafIndex) } if entry.Timestamp != expected.Timestamp { - t.Errorf("%d: timestamp mismatch", i) + t.Errorf("%d: entry.Timestamp %d != expected %d", i, entry.Timestamp, expected.Timestamp) } - if entry.IsPrecert != expected.IsPrecert { - t.Errorf("%d: IsPrecert mismatch", i) + isPrecert := entry.EntryType == rfc6962.PrecertLogEntryType + if isPrecert != expected.IsPrecert { + t.Errorf("%d: isPrecert %v != expected %v", i, isPrecert, expected.IsPrecert) } - if !bytes.Equal(entry.Certificate, expected.Certificate) { - t.Errorf("%d: Certificate mismatch", i) + var cert []byte + if isPrecert { + cert = entry.PrecertEntry.TBSCertificate + if !bytes.Equal(entry.PrecertEntry.IssuerKeyHash[:], expected.IssuerKeyHash) { + t.Errorf("%d: entry.IssuerKeyHash mismatch", i) + } + } else { + cert = entry.X509Entry.Data } - if !bytes.Equal(entry.IssuerKeyHash, expected.IssuerKeyHash) { - t.Errorf("%d: IssuerKeyHash mismatch", i) + if !bytes.Equal(cert, expected.Certificate) { + t.Errorf("%d: entry.Certificate mismatch", i) } } } diff --git a/storage/storage.go b/storage/storage.go index 1b1025349..bbd93a5bc 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -32,6 +32,7 @@ import ( "github.com/transparency-dev/tessera/api/layout" "github.com/transparency-dev/tessera/ctonly" "github.com/transparency-dev/tesseract/internal/logger" + "github.com/transparency-dev/tesseract/internal/types/rfc6962" "github.com/transparency-dev/tesseract/internal/types/staticct" "golang.org/x/mod/sumdb/note" @@ -54,6 +55,7 @@ type KV struct { V []byte } + // IssuerStorage issuer certificates under their hex encoded sha256. type IssuerStorage interface { AddIfNotExist(ctx context.Context, kv []KV) error @@ -100,14 +102,14 @@ func NewCTStorage(ctx context.Context, opts *CTStorageOptions) (*CTStorage, erro return ctStorage, nil } -// DedupFuture returns the timestamp matching a future. +// DedupFuture returns the SCT input matching a future. // // It waits for the entry matching the future to be integrated, fetches it and -// extracts the timestamp from it. +// extracts the SCT input fields from it. // -// TODO(phbnf): cache timestamps (or more) to avoid reparsing the entire leaf bundle -func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (*ctonly.Entry, error) { - return trace1(ctx, "tesseract.storage.DedupFuture", func(ctx context.Context) (*ctonly.Entry, error) { +// TODO(phbnf): cache entries (or more) to avoid reparsing the entire leaf bundle +func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (*rfc6962.CertificateTimestamp, error) { + return trace1(ctx, "tesseract.storage.DedupFuture", func(ctx context.Context) (*rfc6962.CertificateTimestamp, error) { idx, cpRaw, err := cts.awaiter.Await(ctx, f) if err != nil { return nil, fmt.Errorf("error waiting for Tessera index future and its integration: %w", err) @@ -132,11 +134,18 @@ func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (* return nil, fmt.Errorf("failed to fetch entry bundle at index %d: %v", eBIdx, err) } eIdx := idx.Index % layout.EntryBundleWidth - entry, err := staticct.ExtractEntryFromBundle(eBRaw, eIdx) + sct, err := staticct.ExtractSCTInputFromBundle(eBRaw, eIdx) + if err != nil { + return nil, fmt.Errorf("failed to extract SCT input for entry %d in bundle index %d: %v", eIdx, eBIdx, err) + } + extractedIdx, err := staticct.ParseCTExtensionsBytes(sct.Extensions) if err != nil { - return nil, fmt.Errorf("failed to extract entry %d in bundle index %d: %v", eIdx, eBIdx, err) + return nil, fmt.Errorf("failed to extract index from extensions: %v", err) + } + if extractedIdx != idx.Index { + return nil, fmt.Errorf("extracted index %d does not match expected index %d", extractedIdx, idx.Index) } - return entry, nil + return sct, nil }) } From 607ba537f8524c68564016fc34df9e2697927b37 Mon Sep 17 00:00:00 2001 From: Philippe Boneff Date: Tue, 7 Jul 2026 14:56:59 +0000 Subject: [PATCH 3/4] reviewers comments + extra edits --- internal/ct/handlers.go | 28 +++++++++++++++++++----- internal/ct/handlers_test.go | 4 ++-- internal/ct/signatures.go | 4 ++++ internal/hammer/hammer.go | 2 +- internal/types/staticct/staticct.go | 5 ++--- internal/types/staticct/staticct_test.go | 1 + storage/storage.go | 2 -- 7 files changed, 32 insertions(+), 14 deletions(-) diff --git a/internal/ct/handlers.go b/internal/ct/handlers.go index b86f11dba..aeb144d96 100644 --- a/internal/ct/handlers.go +++ b/internal/ct/handlers.go @@ -39,9 +39,9 @@ import ( "github.com/transparency-dev/tesseract/internal/otel" "github.com/transparency-dev/tesseract/internal/types/rfc6962" + "github.com/transparency-dev/tesseract/internal/types/staticct" "github.com/transparency-dev/tesseract/internal/types/tls" "github.com/transparency-dev/tesseract/internal/x509util" - "github.com/transparency-dev/tesseract/internal/types/staticct" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/metric" @@ -521,11 +521,12 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt if err != nil { return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("could not resolve duplicate: %v", err) } - if err := entrySCTsMatch(sctInput, entry, index.Index); err != nil { - return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("deduplicated entry in storage does not match submitted entry: %w", err) + if err := sctMatchesEntry(sctInput, entry, index.Index); err != nil { + return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("deduplicated entry in storage does not match submitted entry: %v", err) } } else { var err error + // Build the full leaf at index like Tessera will do, then construct the SCT from it. leafBytes := entry.MerkleTreeLeaf(index.Index) sctInput, err = staticct.ExtractCertificateTimestampFromLeaf(leafBytes) if err != nil { @@ -556,12 +557,21 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt return http.StatusOK, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, nil } -// entrySCTsMatch checks that sctInput fields match with an entry. +// sctMatchesEntry checks that sctInput fields match with an entry. // It checks for all the sctInput fields, except for the timestamp which might // not be set in the entry yet. -func entrySCTsMatch(sct *rfc6962.CertificateTimestamp, e *ctonly.Entry, idx uint64) error { +func sctMatchesEntry(sct *rfc6962.CertificateTimestamp, e *ctonly.Entry, idx uint64) error { + if sct == nil { + return errors.New("sct is nil") + } + if e == nil { + return errors.New("entry is nil") + } extractedIdx, err := staticct.ParseCTExtensionsBytes(sct.Extensions) - if err != nil || extractedIdx != idx { + if err != nil { + return fmt.Errorf("ParseCTExtensionsBytes: %v", err) + } + if extractedIdx != idx { return fmt.Errorf("index mismatch: stored=%d, expected=%d", extractedIdx, idx) } isPrecert := sct.EntryType == rfc6962.PrecertLogEntryType @@ -570,11 +580,17 @@ func entrySCTsMatch(sct *rfc6962.CertificateTimestamp, e *ctonly.Entry, idx uint } var sctCert []byte if isPrecert { + if sct.PrecertEntry == nil { + return errors.New("sct.PrecertEntry is nil") + } sctCert = sct.PrecertEntry.TBSCertificate if !bytes.Equal(sct.PrecertEntry.IssuerKeyHash[:], e.IssuerKeyHash) { return fmt.Errorf("issuerKeyHash mismatch: stored=issuer/%x != submitted=issuer/%x", sct.PrecertEntry.IssuerKeyHash[:], e.IssuerKeyHash) } } else { + if sct.X509Entry == nil { + return errors.New("sct.X509Entry is nil") + } sctCert = sct.X509Entry.Data } if !bytes.Equal(sctCert, e.Certificate) { diff --git a/internal/ct/handlers_test.go b/internal/ct/handlers_test.go index 0d939cb79..594849beb 100644 --- a/internal/ct/handlers_test.go +++ b/internal/ct/handlers_test.go @@ -570,7 +570,7 @@ func TestAddChain(t *testing.T) { } // Check that the Extensions contains the expected index. - idx, err := staticct.ParseCTExtensions(gotRsp.Extensions) + idx, err := staticct.ParseCTExtensionsB64(gotRsp.Extensions) if err != nil { t.Errorf("Failed to parse extensions %q: %v", gotRsp.Extensions, err) } @@ -716,7 +716,7 @@ func TestAddPreChain(t *testing.T) { } // Check that the Extensions contains the expected index. - idx, err := staticct.ParseCTExtensions(gotRsp.Extensions) + idx, err := staticct.ParseCTExtensionsB64(gotRsp.Extensions) if err != nil { t.Errorf("Failed to parse extensions %q: %v", gotRsp.Extensions, err) } diff --git a/internal/ct/signatures.go b/internal/ct/signatures.go index 3ad646415..eaed9ac8e 100644 --- a/internal/ct/signatures.go +++ b/internal/ct/signatures.go @@ -20,6 +20,7 @@ import ( "crypto/sha256" "crypto/x509" "encoding/binary" + "errors" "fmt" "time" @@ -36,6 +37,9 @@ type sctSigner struct { } func (sctSigner *sctSigner) Sign(sctInput *rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) { + if sctInput == nil { + return nil, errors.New("sctInput is nil") + } data, err := tls.Marshal(*sctInput) if err != nil { return nil, fmt.Errorf("failed to serialize SCT data: %v", err) diff --git a/internal/hammer/hammer.go b/internal/hammer/hammer.go index 4becbe67a..e6d72cb0b 100644 --- a/internal/hammer/hammer.go +++ b/internal/hammer/hammer.go @@ -553,7 +553,7 @@ func parseAddChainResponse(body []byte) (uint64, uint64, error) { return 0, 0, fmt.Errorf("can't parse add-chain response: %v", err) } - leafIdx, err := staticct.ParseCTExtensions(resp.Extensions) + leafIdx, err := staticct.ParseCTExtensionsB64(resp.Extensions) if err != nil { return 0, 0, fmt.Errorf("can't parse extensions: %v", err) } diff --git a/internal/types/staticct/staticct.go b/internal/types/staticct/staticct.go index bd828ac6a..5265867b4 100644 --- a/internal/types/staticct/staticct.go +++ b/internal/types/staticct/staticct.go @@ -269,9 +269,9 @@ func ParseCTExtensionsBytes(ext []byte) (uint64, error) { return leafIdx, nil } -// ParseCTExtensions parses base64-encoded CTExtensions into an index. +// ParseCTExtensionsB64 parses base64-encoded CTExtensions into an index. // Code is inspired by https://github.com/FiloSottile/sunlight/blob/main/tile.go. -func ParseCTExtensions(ext string) (uint64, error) { +func ParseCTExtensionsB64(ext string) (uint64, error) { extensionBytes, err := base64.StdEncoding.DecodeString(ext) if err != nil { return 0, fmt.Errorf("can't decode extensions: %v", err) @@ -308,7 +308,6 @@ type Entry struct { LeafIndex uint64 } - // UnmarshalText implements encoding/TextUnmarshaler and reads EntryBundles // which are encoded using the Static CT API spec. func (t *Entry) UnmarshalText(raw []byte) error { diff --git a/internal/types/staticct/staticct_test.go b/internal/types/staticct/staticct_test.go index 16be54158..1b72b9594 100644 --- a/internal/types/staticct/staticct_test.go +++ b/internal/types/staticct/staticct_test.go @@ -13,6 +13,7 @@ // limitations under the License. package staticct + import ( "bytes" "testing" diff --git a/storage/storage.go b/storage/storage.go index bbd93a5bc..4e1b75e0e 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -55,7 +55,6 @@ type KV struct { V []byte } - // IssuerStorage issuer certificates under their hex encoded sha256. type IssuerStorage interface { AddIfNotExist(ctx context.Context, kv []KV) error @@ -218,4 +217,3 @@ func cachedStoreIssuers(s IssuerStorage) func(context.Context, []KV) error { return nil } } - From 65b1807ac5230d48279c1a10d7f1f9833d47aa71 Mon Sep 17 00:00:00 2001 From: Philippe Boneff Date: Thu, 9 Jul 2026 10:00:48 +0000 Subject: [PATCH 4/4] Update SCT generation to pass structs by value Change ExtractCertificateTimestampFromLeaf, ExtractSCTInputFromBundle, DedupFuture, sctMatchesEntry, and Sign to return and accept rfc6962.CertificateTimestamp and ctonly.Entry by value rather than pointer. This eliminates unnecessary defensive nil checks while preserving sync.Pool entry pooling and avoiding memory allocations during extraction. TAG=agy CONV=fdd8e21c-6548-461f-8ead-5ec8915de98d --- internal/ct/ctlog.go | 4 ++-- internal/ct/handlers.go | 12 +++--------- internal/ct/signatures.go | 8 ++------ internal/types/staticct/staticct.go | 30 ++++++++++++++--------------- storage/storage.go | 20 +++++++++---------- 5 files changed, 32 insertions(+), 42 deletions(-) diff --git a/internal/ct/ctlog.go b/internal/ct/ctlog.go index 4497a9b05..d31676182 100644 --- a/internal/ct/ctlog.go +++ b/internal/ct/ctlog.go @@ -33,14 +33,14 @@ type log struct { } // signSCT builds an SCT for a leaf. -type signSCT func(sctInput *rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) +type signSCT func(sctInput rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) // Storage provides functions to store certificates in a static-ct-api log. type Storage interface { // Add assigns an index to the provided Entry, stages the entry for integration, and returns a future for the assigned index. Add(context.Context, *ctonly.Entry) (tessera.IndexFuture, error) // DedupFuture fetches the SCT input fields for a duplicate entry from the log. - DedupFuture(context.Context, tessera.IndexFuture) (*rfc6962.CertificateTimestamp, error) + DedupFuture(context.Context, tessera.IndexFuture) (rfc6962.CertificateTimestamp, error) // AddIssuerChain stores every the chain certificate in a content-addressable store under their sha256 hash. AddIssuerChain(context.Context, []*x509.Certificate) error } diff --git a/internal/ct/handlers.go b/internal/ct/handlers.go index aeb144d96..f3c91c255 100644 --- a/internal/ct/handlers.go +++ b/internal/ct/handlers.go @@ -510,7 +510,7 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt return http.StatusInternalServerError, nil, fmt.Errorf("couldn't resolve tessera future: %v", err) } - var sctInput *rfc6962.CertificateTimestamp + var sctInput rfc6962.CertificateTimestamp if index.IsDup { if ok := opts.RateLimits.AcceptDedup(ctx); !ok { w.Header().Add("Retry-After", strconv.Itoa(rand.IntN(5)+1)) // random retry within [1,6) seconds @@ -521,7 +521,7 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt if err != nil { return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("could not resolve duplicate: %v", err) } - if err := sctMatchesEntry(sctInput, entry, index.Index); err != nil { + if err := sctMatchesEntry(sctInput, *entry, index.Index); err != nil { return http.StatusInternalServerError, []attribute.KeyValue{duplicateKey.Bool(index.IsDup)}, fmt.Errorf("deduplicated entry in storage does not match submitted entry: %v", err) } } else { @@ -560,13 +560,7 @@ func addChainInternal(ctx context.Context, opts *HandlerOptions, log *log, w htt // sctMatchesEntry checks that sctInput fields match with an entry. // It checks for all the sctInput fields, except for the timestamp which might // not be set in the entry yet. -func sctMatchesEntry(sct *rfc6962.CertificateTimestamp, e *ctonly.Entry, idx uint64) error { - if sct == nil { - return errors.New("sct is nil") - } - if e == nil { - return errors.New("entry is nil") - } +func sctMatchesEntry(sct rfc6962.CertificateTimestamp, e ctonly.Entry, idx uint64) error { extractedIdx, err := staticct.ParseCTExtensionsBytes(sct.Extensions) if err != nil { return fmt.Errorf("ParseCTExtensionsBytes: %v", err) diff --git a/internal/ct/signatures.go b/internal/ct/signatures.go index eaed9ac8e..ec787cf5e 100644 --- a/internal/ct/signatures.go +++ b/internal/ct/signatures.go @@ -20,7 +20,6 @@ import ( "crypto/sha256" "crypto/x509" "encoding/binary" - "errors" "fmt" "time" @@ -36,11 +35,8 @@ type sctSigner struct { signer crypto.Signer } -func (sctSigner *sctSigner) Sign(sctInput *rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) { - if sctInput == nil { - return nil, errors.New("sctInput is nil") - } - data, err := tls.Marshal(*sctInput) +func (sctSigner *sctSigner) Sign(sctInput rfc6962.CertificateTimestamp) (*rfc6962.SignedCertificateTimestamp, error) { + data, err := tls.Marshal(sctInput) if err != nil { return nil, fmt.Errorf("failed to serialize SCT data: %v", err) } diff --git a/internal/types/staticct/staticct.go b/internal/types/staticct/staticct.go index 5265867b4..9f30b8e9f 100644 --- a/internal/types/staticct/staticct.go +++ b/internal/types/staticct/staticct.go @@ -138,17 +138,17 @@ func NewCertificateTimestamp(ext []byte, timestamp uint64, isPrecert bool, cert // ExtractCertificateTimestampFromLeaf parses a TLS-encoded MerkleTreeLeaf byte slice // and returns the corresponding CertificateTimestamp. -func ExtractCertificateTimestampFromLeaf(leafBytes []byte) (*rfc6962.CertificateTimestamp, error) { +func ExtractCertificateTimestampFromLeaf(leafBytes []byte) (rfc6962.CertificateTimestamp, error) { var leaf rfc6962.MerkleTreeLeaf if rest, err := tls.Unmarshal(leafBytes, &leaf); err != nil { - return nil, fmt.Errorf("failed to unmarshal MerkleTreeLeaf: %w", err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("failed to unmarshal MerkleTreeLeaf: %w", err) } else if len(rest) > 0 { - return nil, fmt.Errorf("extra data (%d bytes) after MerkleTreeLeaf", len(rest)) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("extra data (%d bytes) after MerkleTreeLeaf", len(rest)) } if leaf.TimestampedEntry == nil { - return nil, errors.New("nil TimestampedEntry in MerkleTreeLeaf") + return rfc6962.CertificateTimestamp{}, errors.New("nil TimestampedEntry in MerkleTreeLeaf") } - return &rfc6962.CertificateTimestamp{ + return rfc6962.CertificateTimestamp{ SCTVersion: rfc6962.V1, SignatureType: rfc6962.CertificateTimestampSignatureType, Timestamp: leaf.TimestampedEntry.Timestamp, @@ -163,7 +163,7 @@ func ExtractCertificateTimestampFromLeaf(leafBytes []byte) (*rfc6962.Certificate // // This implementation avoids unnecessary parsing and allocation by skipping over // uninteresting bytes and ignoring extensions and fingerprints. -func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTimestamp, error) { +func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (rfc6962.CertificateTimestamp, error) { s := cryptobyte.String(ebRaw) i := uint64(0) @@ -171,7 +171,7 @@ func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTime var timestamp uint64 var entryType uint16 if !s.ReadUint64(×tamp) || !s.ReadUint16(&entryType) || timestamp > math.MaxInt64 { - return nil, fmt.Errorf("invalid data tile when reading entry %d", i) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid data tile when reading entry %d", i) } var l32 uint32 @@ -188,9 +188,9 @@ func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTime !s.ReadUint16LengthPrefixed(&extensions) || // fingerprints !s.ReadUint16LengthPrefixed(&fingerprints) { - return nil, fmt.Errorf("invalid data tile x509_entry at index %d", i) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid data tile x509_entry at index %d", i) } - return NewCertificateTimestamp([]byte(extensions), timestamp, false, bytes.Clone(entry), [32]byte{}), nil + return *NewCertificateTimestamp([]byte(extensions), timestamp, false, bytes.Clone(entry), [32]byte{}), nil } if // entry @@ -199,7 +199,7 @@ func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTime !s.ReadUint16(&l16) || !s.Skip(int(l16)) || // fingerprints !s.ReadUint16(&l16) || !s.Skip(int(l16)) { - return nil, fmt.Errorf("invalid data tile x509_entry when reading index %d", i) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid data tile x509_entry when reading index %d", i) } case 1: // precert_entry @@ -218,9 +218,9 @@ func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTime !s.ReadUint24LengthPrefixed((*cryptobyte.String)(&entry)) || // fingerprints !s.ReadUint16LengthPrefixed(&fingerprints) { - return nil, fmt.Errorf("invalid data tile precert_entry at index %d", i) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid data tile precert_entry at index %d", i) } - return NewCertificateTimestamp([]byte(extensions), timestamp, true, bytes.Clone(defangedCrt), issuerKeyHash), nil + return *NewCertificateTimestamp([]byte(extensions), timestamp, true, bytes.Clone(defangedCrt), issuerKeyHash), nil } if // issuer key hash @@ -233,15 +233,15 @@ func ExtractSCTInputFromBundle(ebRaw []byte, N uint64) (*rfc6962.CertificateTime !s.ReadUint24(&l32) || !s.Skip(int(l32)) || // fingerprints !s.ReadUint16(&l16) || !s.Skip(int(l16)) { - return nil, fmt.Errorf("invalid data tile precert_entry when reading index %d", i) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid data tile precert_entry when reading index %d", i) } default: - return nil, fmt.Errorf("invalid data tile: unknown type %d", entryType) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid data tile: unknown type %d", entryType) } i++ } - return nil, fmt.Errorf("requested entry index %d, but found only %d entries", N, i) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("requested entry index %d, but found only %d entries", N, i) } // ParseCTExtensionsBytes parses binary CTExtensions into an index. diff --git a/storage/storage.go b/storage/storage.go index 4e1b75e0e..ca46abb31 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -107,42 +107,42 @@ func NewCTStorage(ctx context.Context, opts *CTStorageOptions) (*CTStorage, erro // extracts the SCT input fields from it. // // TODO(phbnf): cache entries (or more) to avoid reparsing the entire leaf bundle -func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (*rfc6962.CertificateTimestamp, error) { - return trace1(ctx, "tesseract.storage.DedupFuture", func(ctx context.Context) (*rfc6962.CertificateTimestamp, error) { +func (cts *CTStorage) DedupFuture(ctx context.Context, f tessera.IndexFuture) (rfc6962.CertificateTimestamp, error) { + return trace1(ctx, "tesseract.storage.DedupFuture", func(ctx context.Context) (rfc6962.CertificateTimestamp, error) { idx, cpRaw, err := cts.awaiter.Await(ctx, f) if err != nil { - return nil, fmt.Errorf("error waiting for Tessera index future and its integration: %w", err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("error waiting for Tessera index future and its integration: %w", err) } // A https://c2sp.org/static-ct-api logsize is on the second line l := bytes.SplitN(cpRaw, []byte("\n"), 3) if len(l) < 2 { - return nil, errors.New("invalid checkpoint - no size") + return rfc6962.CertificateTimestamp{}, errors.New("invalid checkpoint - no size") } ckptSize, err := strconv.ParseUint(string(l[1]), 10, 64) if err != nil { - return nil, fmt.Errorf("invalid checkpoint - can't extract size: %v", err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("invalid checkpoint - can't extract size: %v", err) } eBIdx := idx.Index / layout.EntryBundleWidth eBRaw, err := cts.reader.ReadEntryBundle(ctx, eBIdx, layout.PartialTileSize(0, eBIdx, ckptSize)) if err != nil { if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("leaf bundle at index %d not found: %v", eBIdx, err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("leaf bundle at index %d not found: %v", eBIdx, err) } - return nil, fmt.Errorf("failed to fetch entry bundle at index %d: %v", eBIdx, err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("failed to fetch entry bundle at index %d: %v", eBIdx, err) } eIdx := idx.Index % layout.EntryBundleWidth sct, err := staticct.ExtractSCTInputFromBundle(eBRaw, eIdx) if err != nil { - return nil, fmt.Errorf("failed to extract SCT input for entry %d in bundle index %d: %v", eIdx, eBIdx, err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("failed to extract SCT input for entry %d in bundle index %d: %v", eIdx, eBIdx, err) } extractedIdx, err := staticct.ParseCTExtensionsBytes(sct.Extensions) if err != nil { - return nil, fmt.Errorf("failed to extract index from extensions: %v", err) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("failed to extract index from extensions: %v", err) } if extractedIdx != idx.Index { - return nil, fmt.Errorf("extracted index %d does not match expected index %d", extractedIdx, idx.Index) + return rfc6962.CertificateTimestamp{}, fmt.Errorf("extracted index %d does not match expected index %d", extractedIdx, idx.Index) } return sct, nil })