Add ctclient, ctcrypto, cttypes, tlstypes packages

This commit is contained in:
Andrew Ayer 2025-05-01 10:34:50 -04:00
parent 3a609ea037
commit 13837fde04
20 changed files with 1855 additions and 10 deletions

108
ctclient/client.go Normal file
View File

@ -0,0 +1,108 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
// Package ctclient implements a client for monitoring RFC6962 and static-ct-api Certificate Transparency logs
package ctclient
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
)
// Create an HTTP client suitable for communicating with CT logs. dialContext, if non-nil, is used for dialing.
func NewHTTPClient(dialContext func(context.Context, string, string) (net.Conn, error)) *http.Client {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConnsPerHost: 10,
DisableKeepAlives: false,
MaxIdleConns: 100,
IdleConnTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
// We have to disable TLS certificate validation because because several logs
// (WoSign, StartCom, GDCA) use certificates that are not widely trusted.
// Since we verify that every response we receive from the log is signed
// by the log's CT public key (either directly, or indirectly via the Merkle Tree),
// TLS certificate validation is not actually necessary. (We don't want to manage
// our own trust store because that adds undesired complexity and would require
// updating should a log ever change to a different CA.)
InsecureSkipVerify: true,
},
DialContext: dialContext,
},
CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("redirects not followed")
},
Timeout: 60 * time.Second,
}
}
var defaultHTTPClient = NewHTTPClient(nil)
func get(ctx context.Context, httpClient *http.Client, fullURL string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", "") // Don't send a User-Agent to make life harder for malicious logs
if httpClient == nil {
httpClient = defaultHTTPClient
}
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
responseBody, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil {
return nil, fmt.Errorf("Get %q: error reading response: %w", fullURL, err)
}
if response.StatusCode != 200 {
return nil, fmt.Errorf("Get %q: %s (%q)", fullURL, response.Status, string(responseBody))
}
return responseBody, nil
}
func getJSON(ctx context.Context, httpClient *http.Client, fullURL string, response any) error {
responseBytes, err := get(ctx, httpClient, fullURL)
if err != nil {
return err
}
if err := json.Unmarshal(responseBytes, response); err != nil {
return fmt.Errorf("Get %q: error parsing response JSON: %w", fullURL, err)
}
return nil
}
func getRoots(ctx context.Context, httpClient *http.Client, logURL *url.URL) ([][]byte, error) {
fullURL := logURL.JoinPath("/ct/v1/get-roots").String()
var parsedResponse struct {
Certificates [][]byte `json:"certificates"`
}
if err := getJSON(ctx, httpClient, fullURL, &parsedResponse); err != nil {
return nil, err
}
return parsedResponse.Certificates, nil
}

53
ctclient/log.go Normal file
View File

@ -0,0 +1,53 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package ctclient
import (
"context"
"software.sslmate.com/src/certspotter/cttypes"
"software.sslmate.com/src/certspotter/merkletree"
)
type Log interface {
GetSTH(context.Context) (*cttypes.SignedTreeHead, string, error)
GetRoots(context.Context) ([][]byte, error)
GetEntries(ctx context.Context, startInclusive, endInclusive uint64) ([]Entry, error)
ReconstructTree(context.Context, *cttypes.SignedTreeHead) (*merkletree.CollapsedTree, error)
}
// IssuerGetter represents a source of issuer certificates.
//
// If a [Log] also implements IssuerGetter, then it is mandatory to provide
// an IssuerGetter when using [Entry]s returned by the [Log]. The IssuerGetter
// may be the Log itself, or your own implementation which retrieves issuers
// from a different source, such as a cache.
//
// If a Log doesn't implement IssuerGetter, then you may pass a nil IssuerGetter
// when using the Log's Entrys.
type IssuerGetter interface {
GetIssuer(ctx context.Context, fingerprint *[32]byte) ([]byte, error)
}
type Entry interface {
LeafInput() []byte
// Returns error from IssuerGetter, otherwise infallible
ExtraData(context.Context, IssuerGetter) ([]byte, error)
// Returns an error if this is not a well-formed precert entry
Precertificate() ([]byte, error)
// Returns an error if this is not a well-formed x509 or precert entry
ChainFingerprints() ([][32]byte, error)
// Returns an error if this is not a well-formed x509 or precert entry, or if IssuerGetter failed
GetChain(context.Context, IssuerGetter) (cttypes.ASN1CertChain, error)
}

225
ctclient/rfc6962.go Normal file
View File

@ -0,0 +1,225 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package ctclient
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/url"
"slices"
"software.sslmate.com/src/certspotter/merkletree"
"software.sslmate.com/src/certspotter/cttypes"
)
type RFC6962Log struct {
URL *url.URL
MaxGetEntriesSize uint64
HTTPClient *http.Client
}
type RFC6962LogEntry struct {
Leaf_input []byte `json:"leaf_input"`
Extra_data []byte `json:"extra_data"`
}
func (ctlog *RFC6962Log) GetSTH(ctx context.Context) (*cttypes.SignedTreeHead, string, error) {
fullURL := ctlog.URL.JoinPath("/ct/v1/get-sth").String()
sth := new(cttypes.SignedTreeHead)
if err := getJSON(ctx, ctlog.HTTPClient, fullURL, sth); err != nil {
return nil, fullURL, err
}
return sth, fullURL, nil
}
func (ctlog *RFC6962Log) GetRoots(ctx context.Context) ([][]byte, error) {
return getRoots(ctx, ctlog.HTTPClient, ctlog.URL)
}
func (ctlog *RFC6962Log) getEntries(ctx context.Context, startInclusive uint64, endInclusive uint64) ([]RFC6962LogEntry, error) {
size := endInclusive - startInclusive + 1
if ctlog.MaxGetEntriesSize != 0 && size > ctlog.MaxGetEntriesSize {
size = ctlog.MaxGetEntriesSize
}
fullURL := ctlog.URL.JoinPath("/ct/v1/get-entries").String()
fullURL += fmt.Sprintf("?start=%d&end=%d", startInclusive, startInclusive+size-1)
var parsedResponse struct {
Entries []RFC6962LogEntry `json:"entries"`
}
if err := getJSON(ctx, ctlog.HTTPClient, fullURL, &parsedResponse); err != nil {
return nil, err
}
if len(parsedResponse.Entries) == 0 {
return nil, fmt.Errorf("Get %q: zero entries returned", fullURL)
}
if uint64(len(parsedResponse.Entries)) > size {
return nil, fmt.Errorf("Get %q: extraneous entries returned", fullURL)
}
return parsedResponse.Entries, nil
}
func (ctlog *RFC6962Log) GetEntries(ctx context.Context, startInclusive uint64, endInclusive uint64) ([]Entry, error) {
nativeEntries, err := ctlog.getEntries(ctx, startInclusive, endInclusive)
if err != nil {
return nil, err
}
entries := make([]Entry, len(nativeEntries))
for i := range nativeEntries {
entries[i] = &nativeEntries[i]
}
return entries, nil
}
type entryAndProofResponse struct {
LeafInput []byte `json:"leaf_input"`
ExtraData []byte `json:"extra_data"`
AuditPath []merkletree.Hash `json:"audit_path"`
}
func (ctlog *RFC6962Log) getEntryAndProof(ctx context.Context, leafIndex uint64, treeSize uint64) (*entryAndProofResponse, error) {
fullURL := ctlog.URL.JoinPath("/ct/v1/get-entry-and-proof").String()
fullURL += fmt.Sprintf("?leaf_index=%d&tree_size=%d", leafIndex, treeSize)
response := new(entryAndProofResponse)
if err := getJSON(ctx, ctlog.HTTPClient, fullURL, response); err != nil {
return nil, err
}
return response, nil
}
type proofResponse struct {
LeafIndex uint64 `json:"leaf_index"`
AuditPath []merkletree.Hash `json:"audit_path"`
}
func (ctlog *RFC6962Log) getProofByHash(ctx context.Context, hash *merkletree.Hash, treeSize uint64) (*proofResponse, error) {
fullURL := ctlog.URL.JoinPath("/ct/v1/get-proof-by-hash").String()
fullURL += fmt.Sprintf("?hash=%s&tree_size=%d", url.QueryEscape(hash.Base64String()), treeSize)
response := new(proofResponse)
if err := getJSON(ctx, ctlog.HTTPClient, fullURL, response); err != nil {
return nil, err
}
return response, nil
}
func (ctlog *RFC6962Log) reconstructTree(ctx context.Context, treeSize uint64) (*merkletree.CollapsedTree, error) {
if treeSize == 0 {
return new(merkletree.CollapsedTree), nil
}
if entryAndProof, err := ctlog.getEntryAndProof(ctx, treeSize-1, treeSize); err == nil {
tree := new(merkletree.CollapsedTree)
slices.Reverse(entryAndProof.AuditPath)
if err := tree.Init(entryAndProof.AuditPath, treeSize-1); err != nil {
return nil, fmt.Errorf("log returned invalid audit proof for entry %d to STH %d: %w", treeSize-1, treeSize, err)
}
tree.Add(merkletree.HashLeaf(entryAndProof.LeafInput))
return tree, nil
}
entries, err := ctlog.getEntries(ctx, treeSize-1, treeSize-1)
if err != nil {
return nil, err
}
leafHash := merkletree.HashLeaf(entries[0].Leaf_input)
tree := new(merkletree.CollapsedTree)
if treeSize > 1 {
response, err := ctlog.getProofByHash(ctx, &leafHash, treeSize)
if err != nil {
return nil, err
}
if response.LeafIndex != treeSize-1 {
// This can happen if the leaf hash is present in the tree in more than one place. Unfortunately, we can't reconstruct when tree if this happens. Fortunately, this is really unlikely, and most logs support get-entry-and-proof anyways.
return nil, fmt.Errorf("unable to reconstruct tree because leaf hash %s is present in tree at more than one index (need proof for index %d but get-proof-by-hash returned proof for index %d)", leafHash.Base64String(), treeSize-1, response.LeafIndex)
}
slices.Reverse(response.AuditPath)
if err := tree.Init(response.AuditPath, treeSize-1); err != nil {
return nil, fmt.Errorf("log returned invalid audit proof for hash %s to STH %d: %w", leafHash.Base64String(), treeSize, err)
}
}
tree.Add(leafHash)
return tree, nil
}
func (ctlog *RFC6962Log) ReconstructTree(ctx context.Context, sth *cttypes.SignedTreeHead) (*merkletree.CollapsedTree, error) {
tree, err := ctlog.reconstructTree(ctx, sth.TreeSize)
if err != nil {
return nil, err
}
if rootHash := tree.CalculateRoot(); rootHash != sth.RootHash {
return nil, fmt.Errorf("calculated root hash (%s) does not match STH (%s) at size %d", rootHash.Base64String(), sth.RootHash.Base64String(), sth.TreeSize)
}
return tree, nil
}
func (entry *RFC6962LogEntry) isX509() bool {
return len(entry.Leaf_input) >= 12 && entry.Leaf_input[0] == 0 && entry.Leaf_input[1] == 0 && entry.Leaf_input[10] == 0 && entry.Leaf_input[11] == 0
}
func (entry *RFC6962LogEntry) isPrecert() bool {
return len(entry.Leaf_input) >= 12 && entry.Leaf_input[0] == 0 && entry.Leaf_input[1] == 0 && entry.Leaf_input[10] == 0 && entry.Leaf_input[11] == 1
}
func (entry *RFC6962LogEntry) LeafInput() []byte {
return entry.Leaf_input
}
func (entry *RFC6962LogEntry) ExtraData(context.Context, IssuerGetter) ([]byte, error) {
return entry.Extra_data, nil
}
func (entry *RFC6962LogEntry) Precertificate() ([]byte, error) {
if !entry.isPrecert() {
return nil, fmt.Errorf("not a precertificate entry")
}
extraData, err := cttypes.ParseExtraDataForPrecertEntry(entry.Extra_data)
if err != nil {
return nil, fmt.Errorf("error parsing extra_data: %w", err)
}
return extraData.PreCertificate, nil
}
func (entry *RFC6962LogEntry) ChainFingerprints() ([][32]byte, error) {
chain, err := entry.parseChain()
if err != nil {
return nil, err
}
fingerprints := make([][32]byte, len(chain))
for i := range chain {
fingerprints[i] = sha256.Sum256(chain[i])
}
return fingerprints, nil
}
func (entry *RFC6962LogEntry) GetChain(context.Context, IssuerGetter) (cttypes.ASN1CertChain, error) {
return entry.parseChain()
}
func (entry *RFC6962LogEntry) parseChain() (cttypes.ASN1CertChain, error) {
switch {
case entry.isX509():
extraData, err := cttypes.ParseExtraDataForX509Entry(entry.Extra_data)
if err != nil {
return nil, fmt.Errorf("error parsing extra_data for X509 entry: %w", err)
}
return extraData, nil
case entry.isPrecert():
extraData, err := cttypes.ParseExtraDataForPrecertEntry(entry.Extra_data)
if err != nil {
return nil, fmt.Errorf("error parsing extra_data for precert entry: %w", err)
}
return extraData.PrecertificateChain, nil
default:
return nil, fmt.Errorf("unknown entry type")
}
}

397
ctclient/static.go Normal file
View File

@ -0,0 +1,397 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package ctclient
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"golang.org/x/crypto/cryptobyte"
"software.sslmate.com/src/certspotter/merkletree"
"software.sslmate.com/src/certspotter/cttypes"
)
const (
staticTileHeight = 8
StaticTileWidth = 1 << staticTileHeight
)
func staticSubtreeSize(level uint64) uint64 { return 1 << (level * staticTileHeight) }
type StaticLog struct {
SubmissionURL *url.URL
MonitoringURL *url.URL
ID cttypes.LogID
HTTPClient *http.Client
}
type StaticLogEntry struct {
timestampedEntry []byte
precertificate []byte // nil iff x509 entry; non-nil iff precert entry
chain [][32]byte
}
func (ctlog *StaticLog) GetSTH(ctx context.Context) (*cttypes.SignedTreeHead, string, error) {
fullURL := ctlog.MonitoringURL.JoinPath("/checkpoint").String()
responseBody, err := get(ctx, ctlog.HTTPClient, fullURL)
if err != nil {
return nil, fullURL, err
}
sth, err := cttypes.ParseCheckpoint(responseBody, ctlog.ID)
if err != nil {
return nil, fullURL, err
}
return sth, fullURL, nil
}
func (ctlog *StaticLog) GetRoots(ctx context.Context) ([][]byte, error) {
return getRoots(ctx, ctlog.HTTPClient, ctlog.SubmissionURL)
}
func (ctlog *StaticLog) getEntries(ctx context.Context, startInclusive uint64, endInclusive uint64) ([]StaticLogEntry, error) {
var (
tile = startInclusive / StaticTileWidth
skip = startInclusive % StaticTileWidth
tileWidth = min(StaticTileWidth, endInclusive+1-tile*StaticTileWidth)
numEntries = tileWidth - skip
)
data, err := ctlog.getDataTile(ctx, tile, tileWidth)
if err != nil {
return nil, err
}
var skippedEntry StaticLogEntry
for i := range skip {
if rest, err := skippedEntry.parse(data); err != nil {
return nil, fmt.Errorf("error parsing skipped entry %d in tile %d: %w", i, tile, err)
} else {
data = rest
}
}
entries := make([]StaticLogEntry, numEntries)
for i := range numEntries {
if rest, err := entries[i].parse(data); err != nil {
return nil, fmt.Errorf("error parsing entry %d in tile %d: %w", skip+i, tile, err)
} else {
data = rest
}
}
return entries, nil
}
func (ctlog *StaticLog) GetEntries(ctx context.Context, startInclusive uint64, endInclusive uint64) ([]Entry, error) {
nativeEntries, err := ctlog.getEntries(ctx, startInclusive, endInclusive)
if err != nil {
return nil, err
}
entries := make([]Entry, len(nativeEntries))
for i := range nativeEntries {
entries[i] = &nativeEntries[i]
}
return entries, nil
}
func (ctlog *StaticLog) ReconstructTree(ctx context.Context, sth *cttypes.SignedTreeHead) (*merkletree.CollapsedTree, error) {
type job struct {
level uint64
offset uint64
width uint64
tree *merkletree.CollapsedTree
err error
}
var jobs []job
for level, size := uint64(0), sth.TreeSize; size > 0; level++ {
fullTiles := size / StaticTileWidth
remainder := size % StaticTileWidth
size = fullTiles
if remainder > 0 {
jobs = append(jobs, job{
level: level,
offset: fullTiles,
width: remainder,
})
}
}
var wg sync.WaitGroup
for i := range jobs {
job := &jobs[i]
wg.Add(1)
go func() {
defer wg.Done()
job.tree, job.err = ctlog.getTileCollapsedTree(ctx, job.level, job.offset, job.width)
}()
}
wg.Wait()
var errs []error
tree := new(merkletree.CollapsedTree)
for i := range jobs {
job := &jobs[len(jobs)-1-i]
if job.err != nil {
errs = append(errs, job.err)
continue
}
if err := tree.Append(*job.tree); err != nil {
panic(err)
}
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
if rootHash := tree.CalculateRoot(); rootHash != sth.RootHash {
return nil, fmt.Errorf("calculated root hash (%s) does not match STH (%s) at size %d", rootHash.Base64String(), sth.RootHash.Base64String(), sth.TreeSize)
}
return tree, nil
}
func (ctlog *StaticLog) getDataTile(ctx context.Context, tile uint64, width uint64) ([]byte, error) {
if width == 0 || width > StaticTileWidth {
panic("width is out of range")
}
var partialErr error
if width < StaticTileWidth {
fullURL := ctlog.MonitoringURL.JoinPath(formatTilePath("data", tile, width)).String()
if data, err := get(ctx, ctlog.HTTPClient, fullURL); err != nil {
partialErr = err
} else {
return data, nil
}
}
fullURL := ctlog.MonitoringURL.JoinPath(formatTilePath("data", tile, 0)).String()
if data, err := get(ctx, ctlog.HTTPClient, fullURL); err != nil {
if partialErr != nil {
return nil, partialErr
} else {
return nil, err
}
} else {
return data, nil
}
}
// returned slice is numHashes*merkletree.HashLen bytes long
func (ctlog *StaticLog) getTile(ctx context.Context, level uint64, tile uint64, numHashes uint64) ([]byte, error) {
if numHashes == 0 || numHashes > StaticTileWidth {
panic("numHashes is out of range")
}
var partialErr error
if numHashes < StaticTileWidth {
fullURL := ctlog.MonitoringURL.JoinPath(formatTilePath(strconv.FormatUint(level, 10), tile, numHashes)).String()
if data, err := get(ctx, ctlog.HTTPClient, fullURL); err != nil {
partialErr = err
} else if expectedLen := merkletree.HashLen * int(numHashes); len(data) != expectedLen {
return nil, fmt.Errorf("%s returned %d bytes instead of expected %d", fullURL, len(data), expectedLen)
} else {
return data, nil
}
}
fullURL := ctlog.MonitoringURL.JoinPath(formatTilePath(strconv.FormatUint(level, 10), tile, 0)).String()
if data, err := get(ctx, ctlog.HTTPClient, fullURL); err != nil {
if partialErr != nil {
return nil, partialErr
} else {
return nil, err
}
} else if expectedLen := merkletree.HashLen * StaticTileWidth; len(data) != expectedLen {
return nil, fmt.Errorf("%s returned %d bytes instead of expected %d", fullURL, len(data), expectedLen)
} else {
desiredLen := merkletree.HashLen * int(numHashes)
return data[:desiredLen], nil
}
}
func (ctlog *StaticLog) getTileCollapsedTree(ctx context.Context, level uint64, tile uint64, numHashes uint64) (*merkletree.CollapsedTree, error) {
data, err := ctlog.getTile(ctx, level, tile, numHashes)
if err != nil {
return nil, err
}
subtreeSize := staticSubtreeSize(level)
offset := staticSubtreeSize(level+1) * tile
tree := new(merkletree.CollapsedTree)
if err := tree.InitSubtree(offset, nil, 0); err != nil {
panic(err)
}
for i := uint64(0); i < numHashes; i++ {
hash := (merkletree.Hash)(data[i*merkletree.HashLen : (i+1)*merkletree.HashLen])
var subtree merkletree.CollapsedTree
if err := subtree.InitSubtree(offset+i*subtreeSize, []merkletree.Hash{hash}, subtreeSize); err != nil {
panic(err)
}
if err := tree.Append(subtree); err != nil {
panic(err)
}
}
return tree, nil
}
func (ctlog *StaticLog) GetIssuer(ctx context.Context, fingerprint *[32]byte) ([]byte, error) {
fullURL := ctlog.MonitoringURL.JoinPath("/issuer/" + hex.EncodeToString(fingerprint[:])).String()
data, err := get(ctx, ctlog.HTTPClient, fullURL)
if err != nil {
return nil, err
}
if gotFingerprint := sha256.Sum256(data); gotFingerprint != *fingerprint {
return nil, fmt.Errorf("%s returned incorrect data with fingerprint %x", fullURL, gotFingerprint[:])
}
return data, nil
}
func (entry *StaticLogEntry) parse(input []byte) ([]byte, error) {
var skipped cryptobyte.String
str := cryptobyte.String(input)
// TimestampedEntry.timestamp
if !str.Skip(8) {
return nil, fmt.Errorf("error reading timestamp")
}
// TimestampedEntry.entry_type
var entryType uint16
if !str.ReadUint16(&entryType) {
return nil, fmt.Errorf("error reading entry type")
}
// TimestampedEntry.signed_entry
if entryType == 0 {
if !str.ReadUint24LengthPrefixed(&skipped) {
return nil, fmt.Errorf("error reading certificate")
}
} else if entryType == 1 {
if !str.Skip(32) {
return nil, fmt.Errorf("error reading issuer_key_hash")
}
if !str.ReadUint24LengthPrefixed(&skipped) {
return nil, fmt.Errorf("error reading tbs_certificate")
}
} else {
return nil, fmt.Errorf("invalid entry type %d", entryType)
}
// TimestampedEntry.extensions
if !str.ReadUint16LengthPrefixed(&skipped) {
return nil, fmt.Errorf("error reading extensions")
}
timestampedEntryLen := len(input) - len(str)
entry.timestampedEntry = input[:timestampedEntryLen]
// precertificate
if entryType == 1 {
var precertificate cryptobyte.String
if !str.ReadUint24LengthPrefixed(&precertificate) {
return nil, fmt.Errorf("error reading precertificate")
}
entry.precertificate = precertificate
} else {
entry.precertificate = nil
}
// certificate_chain
var chainBytes cryptobyte.String
if !str.ReadUint16LengthPrefixed(&chainBytes) {
return nil, fmt.Errorf("error reading certificate_chain")
}
entry.chain = make([][32]byte, 0, len(chainBytes)/32)
for !chainBytes.Empty() {
var fingerprint [32]byte
if !chainBytes.CopyBytes(fingerprint[:]) {
return nil, fmt.Errorf("error reading fingerprint in certificate_chain")
}
entry.chain = append(entry.chain, fingerprint)
}
return str, nil
}
func (entry *StaticLogEntry) LeafInput() []byte {
return append([]byte{0, 0}, entry.timestampedEntry...)
}
func (entry *StaticLogEntry) ExtraData(ctx context.Context, issuerGetter IssuerGetter) ([]byte, error) {
b := cryptobyte.NewBuilder(nil)
if entry.precertificate != nil {
b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes(entry.precertificate)
})
}
b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
for _, fingerprint := range entry.chain {
b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
cert, err := issuerGetter.GetIssuer(ctx, &fingerprint)
if err != nil {
panic(cryptobyte.BuildError{Err: fmt.Errorf("error getting issuer %x: %w", fingerprint, err)})
}
b.AddBytes(cert)
})
}
})
return b.Bytes()
}
func (entry *StaticLogEntry) Precertificate() ([]byte, error) {
if entry.precertificate == nil {
return nil, fmt.Errorf("not a precertificate entry")
}
return entry.precertificate, nil
}
func (entry *StaticLogEntry) ChainFingerprints() ([][32]byte, error) {
return entry.chain, nil
}
func (entry *StaticLogEntry) GetChain(ctx context.Context, issuerGetter IssuerGetter) (cttypes.ASN1CertChain, error) {
var (
chain = make(cttypes.ASN1CertChain, len(entry.chain))
errs = make([]error, len(entry.chain))
)
var wg sync.WaitGroup
for i, fingerprint := range entry.chain {
wg.Add(1)
go func() {
defer wg.Done()
chain[i], errs[i] = issuerGetter.GetIssuer(ctx, &fingerprint)
}()
}
wg.Wait()
if err := errors.Join(errs...); err != nil {
return nil, err
}
return chain, nil
}
func formatTilePath(level string, tile uint64, partial uint64) string {
path := "tile/" + level + "/" + formatTileIndex(tile)
if partial != 0 {
path += fmt.Sprintf(".p/%d", partial)
}
return path
}
func formatTileIndex(tile uint64) string {
const base = 1000
str := fmt.Sprintf("%03d", tile%base)
for tile >= base {
tile = tile / base
str = fmt.Sprintf("x%03d/%s", tile%base, str)
}
return str
}

38
ctclient/static_test.go Normal file
View File

@ -0,0 +1,38 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package ctclient
import (
"testing"
)
func TestFormatTileIndex(t *testing.T) {
tests := []struct {
in uint64
out string
}{
{0, "000"},
{1, "001"},
{12, "012"},
{105, "105"},
{1000, "x001/000"},
{1050, "x001/050"},
{52123, "x052/123"},
{999001, "x999/001"},
{1999001, "x001/x999/001"},
{15999001, "x015/x999/001"},
}
for i, test := range tests {
result := formatTileIndex(test.in)
if result != test.out {
t.Errorf("#%d: formatTileIndex(%q) = %q, want %q", i, test.in, result, test.out)
}
}
}

67
ctcrypto/key.go Normal file
View File

@ -0,0 +1,67 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package ctcrypto
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"fmt"
"software.sslmate.com/src/certspotter/tlstypes"
)
type PublicKey []byte
func (key PublicKey) Verify(input SignatureInput, signature tlstypes.DigitallySigned) error {
parsedKey, err := x509.ParsePKIXPublicKey(key)
if err != nil {
return fmt.Errorf("error parsing log key: %w", err)
}
switch key := parsedKey.(type) {
case *rsa.PublicKey:
if signature.Algorithm.Signature != tlstypes.RSA {
return fmt.Errorf("log key is RSA but this is not an RSA signature")
}
if signature.Algorithm.Hash != tlstypes.SHA256 {
return fmt.Errorf("unsupported hash algorithm %v (only SHA-256 is allowed in CT)", signature.Algorithm.Hash)
}
if rsa.VerifyPKCS1v15((*rsa.PublicKey)(key), crypto.SHA256, input[:], signature.Signature) != nil {
return fmt.Errorf("RSA signature is incorrect")
}
return nil
case *ecdsa.PublicKey:
if signature.Algorithm.Signature != tlstypes.ECDSA {
return fmt.Errorf("log key is ECDSA but this is not an ECDSA signature")
}
if signature.Algorithm.Hash != tlstypes.SHA256 {
return fmt.Errorf("unsupported hash algorithm %v (only SHA-256 is allowed in CT)", signature.Algorithm.Hash)
}
if !ecdsa.VerifyASN1((*ecdsa.PublicKey)(key), input[:], signature.Signature) {
return fmt.Errorf("ECDSA signature is incorrect")
}
default:
return fmt.Errorf("unsupported public key type %T (CT only allows RSA and ECDSA)", key)
}
return nil
}
func (key PublicKey) MarshalBinary() ([]byte, error) {
return bytes.Clone(key), nil
}
func (key *PublicKey) UnmarshalBinary(data []byte) error {
*key = bytes.Clone(data)
return nil
}

55
ctcrypto/signatures.go Normal file
View File

@ -0,0 +1,55 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package ctcrypto
import (
"crypto/sha256"
"golang.org/x/crypto/cryptobyte"
"software.sslmate.com/src/certspotter/cttypes"
)
type SignatureInput [32]byte
func MakeSignatureInput(message []byte) SignatureInput {
return sha256.Sum256(message)
}
func SignatureInputForPrecertSCT(sct *cttypes.SignedCertificateTimestamp, precert cttypes.PreCert) SignatureInput {
var builder cryptobyte.Builder
builder.AddValue(sct.SCTVersion)
builder.AddValue(cttypes.CertificateTimestampSignatureType)
builder.AddUint64(sct.Timestamp)
builder.AddValue(cttypes.PrecertEntryType)
builder.AddValue(&precert)
builder.AddValue(sct.Extensions)
return MakeSignatureInput(builder.BytesOrPanic())
}
func SignatureInputForCertSCT(sct *cttypes.SignedCertificateTimestamp, cert cttypes.ASN1Cert) SignatureInput {
var builder cryptobyte.Builder
builder.AddValue(sct.SCTVersion)
builder.AddValue(cttypes.CertificateTimestampSignatureType)
builder.AddUint64(sct.Timestamp)
builder.AddValue(cttypes.X509EntryType)
builder.AddValue(cert)
builder.AddValue(sct.Extensions)
return MakeSignatureInput(builder.BytesOrPanic())
}
func SignatureInputForSTH(sth *cttypes.SignedTreeHead) SignatureInput {
var builder cryptobyte.Builder
builder.AddValue(cttypes.V1)
builder.AddValue(cttypes.TreeHashSignatureType)
builder.AddUint64(sth.Timestamp)
builder.AddUint64(sth.TreeSize)
builder.AddBytes(sth.RootHash[:])
return MakeSignatureInput(builder.BytesOrPanic())
}

121
cttypes/certs.go Normal file
View File

@ -0,0 +1,121 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"fmt"
"golang.org/x/crypto/cryptobyte"
)
type TBSCertificate []byte
type ASN1Cert []byte
type ASN1CertChain []ASN1Cert
// Corresponds to the PreCert structure in RFC 6962. PreCert is a misnomer because this is really a TBSCertificate, not a precertificate.
type PreCert struct {
IssuerKeyHash [32]byte
TBSCertificate TBSCertificate
}
type PrecertChainEntry struct {
PreCertificate ASN1Cert
PrecertificateChain ASN1CertChain
}
func (v *TBSCertificate) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint24LengthPrefixed((*cryptobyte.String)(v))
}
func (v TBSCertificate) Marshal(b *cryptobyte.Builder) error {
b.AddUint24LengthPrefixed(addBytesFunc(v))
return nil
}
func (v *ASN1Cert) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint24LengthPrefixed((*cryptobyte.String)(v))
}
func (v ASN1Cert) Marshal(b *cryptobyte.Builder) error {
b.AddUint24LengthPrefixed(addBytesFunc(v))
return nil
}
func (v *ASN1CertChain) Unmarshal(s *cryptobyte.String) bool {
chainBytes := new(cryptobyte.String)
if !s.ReadUint24LengthPrefixed(chainBytes) {
return false
}
*v = []ASN1Cert{}
for !chainBytes.Empty() {
var cert ASN1Cert
if !cert.Unmarshal(chainBytes) {
return false
}
*v = append(*v, cert)
}
return true
}
func (v ASN1CertChain) Marshal(b *cryptobyte.Builder) error {
b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
for _, cert := range v {
b.AddValue(cert)
}
})
return nil
}
func (precert *PreCert) Unmarshal(s *cryptobyte.String) error {
if !s.CopyBytes(precert.IssuerKeyHash[:]) {
return fmt.Errorf("error reading PreCert issuer_key_hash")
}
if !precert.TBSCertificate.Unmarshal(s) {
return fmt.Errorf("error reading PreCert tbs_certificate")
}
return nil
}
func (v *PreCert) Marshal(b *cryptobyte.Builder) error {
b.AddBytes(v.IssuerKeyHash[:])
b.AddValue(v.TBSCertificate)
return nil
}
func (entry *PrecertChainEntry) Unmarshal(s *cryptobyte.String) error {
if !entry.PreCertificate.Unmarshal(s) {
return fmt.Errorf("error reading PrecertChainEntry pre_certificate")
}
if !entry.PrecertificateChain.Unmarshal(s) {
return fmt.Errorf("error reading PrecertChainEntry preeertificate_chain")
}
return nil
}
func ParseExtraDataForX509Entry(extraData []byte) (ASN1CertChain, error) {
str := cryptobyte.String(extraData)
var chain ASN1CertChain
if !chain.Unmarshal(&str) {
return nil, fmt.Errorf("error reading ASN.1Cert chain")
}
if !str.Empty() {
return nil, fmt.Errorf("trailing garbage after ASN.1Cert chain")
}
return chain, nil
}
func ParseExtraDataForPrecertEntry(extraData []byte) (*PrecertChainEntry, error) {
str := cryptobyte.String(extraData)
entry := new(PrecertChainEntry)
if err := entry.Unmarshal(&str); err != nil {
return nil, err
}
if !str.Empty() {
return nil, fmt.Errorf("trailing garbage after PrecertChainEntry")
}
return entry, nil
}

112
cttypes/checkpoint.go Normal file
View File

@ -0,0 +1,112 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strconv"
"strings"
"software.sslmate.com/src/certspotter/merkletree"
"software.sslmate.com/src/certspotter/tlstypes"
)
func chompLine(input []byte) (string, []byte, bool) {
newline := bytes.IndexByte(input, '\n')
if newline == -1 {
return "", nil, false
}
return string(input[:newline]), input[newline+1:], true
}
func makeCheckpointKeyID(origin string, logID LogID) [4]byte {
h := sha256.New()
h.Write([]byte(origin))
h.Write([]byte{'\n', 0x05})
h.Write(logID[:])
var digest [sha256.Size]byte
h.Sum(digest[:0])
return [4]byte(digest[:4])
}
func ParseCheckpoint(input []byte, logID LogID) (*SignedTreeHead, error) {
// origin
origin, input, _ := chompLine(input)
// tree size
sizeLine, input, _ := chompLine(input)
treeSize, err := strconv.ParseUint(sizeLine, 10, 64)
if err != nil {
return nil, fmt.Errorf("malformed tree size: %w", err)
}
// root hash
hashLine, input, _ := chompLine(input)
rootHash, err := base64.StdEncoding.DecodeString(hashLine)
if err != nil {
return nil, fmt.Errorf("malformed root hash: %w", err)
}
if len(rootHash) != merkletree.HashLen {
return nil, fmt.Errorf("root hash has wrong length (should be %d bytes long, not %d)", merkletree.HashLen, len(rootHash))
}
// 0 or more non-empty extension lines (ignored)
for {
line, rest, ok := chompLine(input)
if !ok {
return nil, errors.New("signed note ended prematurely")
}
input = rest
if len(line) == 0 {
break
}
}
// signature lines
signaturePrefix := "\u2014 " + origin + " "
keyID := makeCheckpointKeyID(origin, logID)
for {
signatureLine, rest, ok := chompLine(input)
if !ok {
return nil, errors.New("signed note is missing signature from the log")
}
input = rest
if !strings.HasPrefix(signatureLine, signaturePrefix) {
continue
}
signatureBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(signatureLine, signaturePrefix))
if err != nil {
return nil, fmt.Errorf("malformed signature: %w", err)
}
if !bytes.HasPrefix(signatureBytes, keyID[:]) {
continue
}
if len(signatureBytes) < 12 {
return nil, errors.New("malformed signature: too short")
}
timestamp := binary.BigEndian.Uint64(signatureBytes[4:12])
signature, err := tlstypes.ParseDigitallySigned(signatureBytes[12:])
if err != nil {
return nil, fmt.Errorf("malformed signature: %w", err)
}
return &SignedTreeHead{
TreeSize: treeSize,
Timestamp: timestamp,
RootHash: (merkletree.Hash)(rootHash),
Signature: *signature,
}, nil
}
}

View File

@ -0,0 +1,89 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"bytes"
"software.sslmate.com/src/certspotter/tlstypes"
"testing"
)
func TestParseCheckpoint(t *testing.T) {
logID := LogID{0x49, 0x4d, 0x90, 0x49, 0xb8, 0xaf, 0x3e, 0x5a, 0xca, 0xba, 0x99, 0x3e, 0x4c, 0x1f, 0x30, 0x56, 0x73, 0x7a, 0xa9, 0xf9, 0x6d, 0x00, 0xf7, 0xb0, 0xb9, 0xb2, 0x51, 0x06, 0xf7, 0xbe, 0x1a, 0x8f}
expected := SignedTreeHead{
TreeSize: 820495916,
Timestamp: 1719511711300,
RootHash: [...]byte{0xc0, 0x3a, 0x6b, 0xe9, 0xf1, 0x1d, 0xc9, 0xcc, 0x20, 0x89, 0xe4, 0x45, 0xa7, 0x3f, 0x61, 0x41, 0xee, 0x82, 0xe8, 0x0d, 0x2d, 0x83, 0xa2, 0xb6, 0x65, 0x53, 0xd4, 0x96, 0xd7, 0x1e, 0xd2, 0x12},
Signature: tlstypes.DigitallySigned{
Algorithm: tlstypes.SignatureAndHashAlgorithm{
Hash: tlstypes.SHA256,
Signature: tlstypes.ECDSA,
},
Signature: []byte{0x30, 0x46, 0x02, 0x21, 0x00, 0xd0, 0x0d, 0x31, 0x91, 0x50, 0x80, 0x62, 0xfa, 0xb0, 0xf9, 0xf7, 0x63, 0x61, 0x78, 0x95, 0x2b, 0x9c, 0x19, 0x22, 0x3a, 0x1d, 0x08, 0xc5, 0x68, 0x0e, 0xd0, 0x8b, 0x3b, 0x79, 0x18, 0x88, 0x86, 0x02, 0x21, 0x00, 0xd5, 0x74, 0xae, 0x8c, 0x1d, 0x1d, 0x7a, 0x4e, 0x80, 0x6c, 0x36, 0x46, 0x81, 0xb4, 0x7c, 0x91, 0x78, 0xc0, 0x3f, 0xdc, 0xc0, 0xab, 0xa5, 0x90, 0x40, 0x8d, 0x0e, 0xf6, 0x2c, 0x83, 0xa9, 0x34},
},
}
sthStrings := []string{
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\nextension line\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\nextension line\nanother extension line\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n\u2014 someoneelse.example c2lnbmF0dXJlCg==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 someoneelse.example c2lnbmF0dXJlCg==\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\nextension line\nanother extension line\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2c2lnbmF0dXJlCg==\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
}
for i, str := range sthStrings {
parsed, err := ParseCheckpoint([]byte(str), logID)
if err != nil {
t.Errorf("%d: Unexpected error: %s", i, err)
return
}
if parsed.TreeSize != expected.TreeSize {
t.Errorf("%d: wrong tree size", i)
return
}
if parsed.Timestamp != expected.Timestamp {
t.Errorf("%d: wrong timestamp", i)
return
}
if !bytes.Equal(parsed.RootHash[:], expected.RootHash[:]) {
t.Errorf("%d: wrong root hash", i)
return
}
if !(parsed.Signature.Algorithm == expected.Signature.Algorithm && bytes.Equal(parsed.Signature.Signature, expected.Signature.Signature)) {
t.Errorf("%d: wrong signature", i)
return
}
}
}
func TestParseCheckpointFailure(t *testing.T) {
logID := LogID{0x49, 0x4d, 0x90, 0x49, 0xb8, 0xaf, 0x3e, 0x5a, 0xca, 0xba, 0x99, 0x3e, 0x4c, 0x1f, 0x30, 0x56, 0x73, 0x7a, 0xa9, 0xf9, 0x6d, 0x00, 0xf7, 0xb0, 0xb9, 0xb2, 0x51, 0x06, 0xf7, 0xbe, 0x1a, 0x8f}
sthStrings := []string{
"",
"\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 oak.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 bm90YXNpZ25hdHVyZQo=\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 notbase64!\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916!\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEd!ycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n\u2014 sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
"sycamore.ct.letsencrypt.org/2024h2\n820495916\nwDpr6fEdycwgieRFpz9hQe6C6A0tg6K2ZVPUltce0hI=\n\n- sycamore.ct.letsencrypt.org/2024h2 PdkIagAAAZBa4n5EBAMASDBGAiEA0A0xkVCAYvqw+fdjYXiVK5wZIjodCMVoDtCLO3kYiIYCIQDVdK6MHR16ToBsNkaBtHyReMA/3MCrpZBAjQ72LIOpNA==\n",
}
for i, str := range sthStrings {
_, err := ParseCheckpoint([]byte(str), logID)
if err == nil {
t.Errorf("%d: Unexpected success", i)
return
}
}
}

20
cttypes/helpers.go Normal file
View File

@ -0,0 +1,20 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"golang.org/x/crypto/cryptobyte"
)
func addBytesFunc(v []byte) cryptobyte.BuilderContinuation {
return func(b *cryptobyte.Builder) {
b.AddBytes(v)
}
}

60
cttypes/logid.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"encoding/base64"
"fmt"
"golang.org/x/crypto/cryptobyte"
)
type LogID [32]byte
func (v *LogID) Unmarshal(s *cryptobyte.String) bool {
return s.CopyBytes((*v)[:])
}
func (v LogID) Marshal(b *cryptobyte.Builder) error {
b.AddBytes(v[:])
return nil
}
func (id *LogID) UnmarshalBinary(bytes []byte) error {
if len(bytes) != len(*id) {
return fmt.Errorf("LogID has wrong length (should be %d, not %d)", len(*id), len(bytes))
}
*id = (LogID)(bytes)
return nil
}
func (id LogID) MarshalBinary() ([]byte, error) {
return id[:], nil
}
func (id *LogID) UnmarshalText(textData []byte) error {
if len(textData) != 44 {
return fmt.Errorf("LogID has wrong length (should be %d, not %d)", 44, len(textData))
}
var bytes [33]byte
if n, err := base64.StdEncoding.Decode(bytes[:], textData); err != nil {
return fmt.Errorf("LogID contains invalid base64: %w", err)
} else if n != 32 {
return fmt.Errorf("LogID has wrong length (should be %d bytes, not %d)", 32, n)
}
copy(id[:], bytes[:])
return nil
}
func (id LogID) MarshalText() ([]byte, error) {
encodedBytes := make([]byte, 44)
base64.StdEncoding.Encode(encodedBytes, id[:])
return encodedBytes, nil
}
func (id LogID) Base64String() string {
return base64.StdEncoding.EncodeToString(id[:])
}

213
cttypes/merkleleaf.go Normal file
View File

@ -0,0 +1,213 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"fmt"
"golang.org/x/crypto/cryptobyte"
"software.sslmate.com/src/certspotter/merkletree"
)
type MerkleLeafType uint8
const (
TimestampedEntryType MerkleLeafType = 0
)
type LogEntryType uint16
const (
X509EntryType LogEntryType = 0
PrecertEntryType LogEntryType = 1
)
type CTExtensions []byte
type MerkleTreeLeaf struct {
Version Version
LeafType MerkleLeafType
TimestampedEntry *TimestampedEntry
}
type TimestampedEntry struct {
Timestamp uint64
EntryType LogEntryType
SignedEntryASN1Cert *ASN1Cert
SignedEntryPreCert *PreCert
Extensions CTExtensions
}
func (v *MerkleLeafType) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint8((*uint8)(v))
}
func (v MerkleLeafType) Marshal(b *cryptobyte.Builder) error {
b.AddUint8(uint8(v))
return nil
}
func (v *LogEntryType) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint16((*uint16)(v))
}
func (v LogEntryType) Marshal(b *cryptobyte.Builder) error {
b.AddUint16(uint16(v))
return nil
}
func (v *CTExtensions) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint16LengthPrefixed((*cryptobyte.String)(v))
}
func (v CTExtensions) Marshal(b *cryptobyte.Builder) error {
b.AddUint16LengthPrefixed(addBytesFunc(v))
return nil
}
func (leaf *MerkleTreeLeaf) Unmarshal(s *cryptobyte.String) error {
if !leaf.Version.Unmarshal(s) {
return fmt.Errorf("error reading MerkleTreeLeaf version")
}
if leaf.Version != V1 {
return fmt.Errorf("unsupported Version 0x%02x", leaf.Version)
}
if !leaf.LeafType.Unmarshal(s) {
return fmt.Errorf("error reading MerkleTreeLeaf leaf_type")
}
switch leaf.LeafType {
case TimestampedEntryType:
leaf.TimestampedEntry = new(TimestampedEntry)
if err := leaf.TimestampedEntry.Unmarshal(s); err != nil {
return err
}
default:
return fmt.Errorf("unrecognized MerkleLeafType 0x%02x", leaf.LeafType)
}
return nil
}
func (v *MerkleTreeLeaf) Marshal(b *cryptobyte.Builder) error {
b.AddValue(v.Version)
b.AddValue(v.LeafType)
switch v.LeafType {
case TimestampedEntryType:
b.AddValue(v.TimestampedEntry)
}
return nil
}
func (v *MerkleTreeLeaf) Bytes() ([]byte, error) {
var builder cryptobyte.Builder
builder.AddValue(v)
return builder.Bytes()
}
func (v *MerkleTreeLeaf) Hash() merkletree.Hash {
var builder cryptobyte.Builder
builder.AddValue(v)
return merkletree.HashLeaf(builder.BytesOrPanic())
}
func (entry *TimestampedEntry) Unmarshal(s *cryptobyte.String) error {
if !s.ReadUint64(&entry.Timestamp) {
return fmt.Errorf("error reading TimestampedEntry timestamp")
}
if !entry.EntryType.Unmarshal(s) {
return fmt.Errorf("error reading TimestampedEntry entry_type")
}
switch entry.EntryType {
case X509EntryType:
entry.SignedEntryASN1Cert = new(ASN1Cert)
if !entry.SignedEntryASN1Cert.Unmarshal(s) {
return fmt.Errorf("error reading TimestampedEntry signed_entry ASN.1Cert")
}
case PrecertEntryType:
entry.SignedEntryPreCert = new(PreCert)
if err := entry.SignedEntryPreCert.Unmarshal(s); err != nil {
return fmt.Errorf("error reading TimestampedEntryType signed_entry: %w", err)
}
default:
return fmt.Errorf("unrecognized TimestampedEntryType 0x%02x", entry.EntryType)
}
if !entry.Extensions.Unmarshal(s) {
return fmt.Errorf("error reading TimestampedEntry extensions")
}
return nil
}
func (v *TimestampedEntry) Marshal(b *cryptobyte.Builder) error {
b.AddUint64(v.Timestamp)
b.AddValue(v.EntryType)
switch v.EntryType {
case X509EntryType:
b.AddValue(v.SignedEntryASN1Cert)
case PrecertEntryType:
b.AddValue(v.SignedEntryPreCert)
}
b.AddValue(v.Extensions)
return nil
}
func ParseLeafInput(leafInput []byte) (*MerkleTreeLeaf, error) {
str := cryptobyte.String(leafInput)
leaf := new(MerkleTreeLeaf)
if err := leaf.Unmarshal(&str); err != nil {
return nil, err
}
if !str.Empty() {
return nil, fmt.Errorf("trailing garbage after MerkleTreeLeaf")
}
return leaf, nil
}
func MerkleTreeLeafForCert(timestamp uint64, extensions []byte, cert ASN1Cert) *MerkleTreeLeaf {
return &MerkleTreeLeaf{
Version: V1,
LeafType: TimestampedEntryType,
TimestampedEntry: &TimestampedEntry{
Timestamp: timestamp,
EntryType: X509EntryType,
SignedEntryASN1Cert: &cert,
Extensions: extensions,
},
}
}
func MerkleTreeLeafForCertSCT(sct *SignedCertificateTimestamp, cert ASN1Cert) *MerkleTreeLeaf {
return &MerkleTreeLeaf{
Version: sct.SCTVersion,
LeafType: TimestampedEntryType,
TimestampedEntry: &TimestampedEntry{
Timestamp: sct.Timestamp,
EntryType: X509EntryType,
SignedEntryASN1Cert: &cert,
Extensions: sct.Extensions,
},
}
}
func MerkleTreeLeafForPrecert(timestamp uint64, extensions []byte, precert PreCert) *MerkleTreeLeaf {
return &MerkleTreeLeaf{
Version: V1,
LeafType: TimestampedEntryType,
TimestampedEntry: &TimestampedEntry{
Timestamp: timestamp,
EntryType: PrecertEntryType,
SignedEntryPreCert: &precert,
Extensions: extensions,
},
}
}
func MerkleTreeLeafForPrecertSCT(sct *SignedCertificateTimestamp, precert PreCert) *MerkleTreeLeaf {
return &MerkleTreeLeaf{
Version: sct.SCTVersion,
LeafType: TimestampedEntryType,
TimestampedEntry: &TimestampedEntry{
Timestamp: sct.Timestamp,
EntryType: PrecertEntryType,
SignedEntryPreCert: &precert,
Extensions: sct.Extensions,
},
}
}

58
cttypes/sct.go Normal file
View File

@ -0,0 +1,58 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"fmt"
"golang.org/x/crypto/cryptobyte"
"software.sslmate.com/src/certspotter/tlstypes"
)
type SignedCertificateTimestamp struct {
SCTVersion Version `json:"sct_version"`
ID LogID `json:"id"`
Timestamp uint64 `json:"timestamp"`
Extensions CTExtensions `json:"extensions"`
Signature tlstypes.DigitallySigned `json:"signature"`
}
func (sct *SignedCertificateTimestamp) Unmarshal(s *cryptobyte.String) error {
if !sct.SCTVersion.Unmarshal(s) {
return fmt.Errorf("error reading SCT version")
}
if sct.SCTVersion != V1 {
return fmt.Errorf("unsupported SCT version 0x%02x", sct.SCTVersion)
}
if !sct.ID.Unmarshal(s) {
return fmt.Errorf("error reading SCT id")
}
if !s.ReadUint64(&sct.Timestamp) {
return fmt.Errorf("error reading SCT timestamp")
}
if !sct.Extensions.Unmarshal(s) {
return fmt.Errorf("error reading SCT extensions")
}
if !sct.Signature.Unmarshal(s) {
return fmt.Errorf("error reading SCT signature")
}
return nil
}
func ParseSignedCertificateTimestamp(data []byte) (*SignedCertificateTimestamp, error) {
str := cryptobyte.String(data)
sct := new(SignedCertificateTimestamp)
if err := sct.Unmarshal(&str); err != nil {
return nil, err
}
if !str.Empty() {
return nil, fmt.Errorf("trailing garbage after SignedCertificateTimestamp")
}
return sct, nil
}

29
cttypes/signatures.go Normal file
View File

@ -0,0 +1,29 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"golang.org/x/crypto/cryptobyte"
)
type SignatureType uint8
const (
CertificateTimestampSignatureType SignatureType = 0
TreeHashSignatureType SignatureType = 1
)
func (v *SignatureType) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint8((*uint8)(v))
}
func (v SignatureType) Marshal(b *cryptobyte.Builder) error {
b.AddUint8(uint8(v))
return nil
}

33
cttypes/sth.go Normal file
View File

@ -0,0 +1,33 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"software.sslmate.com/src/certspotter/merkletree"
"software.sslmate.com/src/certspotter/tlstypes"
"time"
)
type SignedTreeHead struct {
TreeSize uint64 `json:"tree_size"`
Timestamp uint64 `json:"timestamp"`
RootHash merkletree.Hash `json:"sha256_root_hash"`
Signature tlstypes.DigitallySigned `json:"tree_head_signature"`
}
type GossipedSignedTreeHead struct {
SignedTreeHead
STHVersion Version `json:"sth_version"`
LogID LogID `json:"log_id"`
}
func (sth *SignedTreeHead) TimestampTime() time.Time {
return time.UnixMilli(int64(sth.Timestamp))
}

30
cttypes/version.go Normal file
View File

@ -0,0 +1,30 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package cttypes
import (
"golang.org/x/crypto/cryptobyte"
)
type Version uint8
const (
V1 Version = 0
)
func (v Version) Marshal(b *cryptobyte.Builder) error {
b.AddUint8(uint8(v))
return nil
}
func (v *Version) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint8((*uint8)(v))
}

9
go.mod
View File

@ -1,10 +1,11 @@
module software.sslmate.com/src/certspotter
go 1.21
go 1.24
require (
golang.org/x/net v0.17.0
golang.org/x/sync v0.4.0
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
golang.org/x/sync v0.13.0
)
require golang.org/x/text v0.13.0 // indirect
require golang.org/x/text v0.24.0 // indirect

14
go.sum
View File

@ -1,6 +1,8 @@
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=

134
tlstypes/signatures.go Normal file
View File

@ -0,0 +1,134 @@
// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package tlstypes
import (
"bytes"
"encoding/json"
"fmt"
"golang.org/x/crypto/cryptobyte"
)
type HashAlgorithm uint8
const (
SHA224 HashAlgorithm = 3
SHA256 HashAlgorithm = 4
SHA384 HashAlgorithm = 5
SHA512 HashAlgorithm = 6
)
type SignatureAlgorithm uint8
const (
RSA SignatureAlgorithm = 1
ECDSA SignatureAlgorithm = 3
)
type SignatureAndHashAlgorithm struct {
Hash HashAlgorithm
Signature SignatureAlgorithm
}
type DigitallySigned struct {
Algorithm SignatureAndHashAlgorithm
Signature []byte
}
func (v HashAlgorithm) Marshal(b *cryptobyte.Builder) error {
b.AddUint8(uint8(v))
return nil
}
func (v *HashAlgorithm) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint8((*uint8)(v))
}
func (v SignatureAlgorithm) Marshal(b *cryptobyte.Builder) error {
b.AddUint8(uint8(v))
return nil
}
func (v *SignatureAlgorithm) Unmarshal(s *cryptobyte.String) bool {
return s.ReadUint8((*uint8)(v))
}
func (v SignatureAndHashAlgorithm) Marshal(b *cryptobyte.Builder) error {
b.AddValue(v.Hash)
b.AddValue(v.Signature)
return nil
}
func (v *SignatureAndHashAlgorithm) Unmarshal(s *cryptobyte.String) bool {
return v.Hash.Unmarshal(s) && v.Signature.Unmarshal(s)
}
func (v DigitallySigned) Marshal(b *cryptobyte.Builder) error {
b.AddValue(v.Algorithm)
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { b.AddBytes(v.Signature) })
return nil
}
func (v *DigitallySigned) Unmarshal(s *cryptobyte.String) bool {
return v.Algorithm.Unmarshal(s) && s.ReadUint16LengthPrefixed((*cryptobyte.String)(&v.Signature))
}
func (v DigitallySigned) Bytes() []byte {
b := cryptobyte.NewBuilder(make([]byte, 0, 4+len(v.Signature)))
b.AddValue(v)
return b.BytesOrPanic()
}
func (v DigitallySigned) MarshalBinary() ([]byte, error) {
b := cryptobyte.NewBuilder(make([]byte, 0, 4+len(v.Signature)))
b.AddValue(v)
return b.Bytes()
}
func (v *DigitallySigned) UnmarshalBinary(data []byte) error {
str := cryptobyte.String(bytes.Clone(data))
if !v.Unmarshal(&str) {
return fmt.Errorf("DigitallySigned bytes are malformed")
}
if !str.Empty() {
return fmt.Errorf("trailing bytes after DigitallySigned")
}
return nil
}
func (v DigitallySigned) MarshalJSON() ([]byte, error) {
b := cryptobyte.NewBuilder(make([]byte, 0, 4+len(v.Signature)))
b.AddValue(v)
if bytes, err := b.Bytes(); err != nil {
return nil, err
} else {
return json.Marshal(bytes)
}
}
func (v *DigitallySigned) UnmarshalJSON(data []byte) error {
str := new(cryptobyte.String)
if err := json.Unmarshal(data, (*[]byte)(str)); err != nil {
return fmt.Errorf("unable to unmarshal DigitallySigned JSON: %w", err)
}
if !v.Unmarshal(str) {
return fmt.Errorf("DigitallySigned bytes are malformed")
}
if !str.Empty() {
return fmt.Errorf("trailing bytes after DigitallySigned")
}
return nil
}
func ParseDigitallySigned(bytes []byte) (*DigitallySigned, error) {
ds := new(DigitallySigned)
str := cryptobyte.String(bytes)
if !ds.Unmarshal(&str) {
return nil, fmt.Errorf("DigitallySigned bytes are malformed")
}
if !str.Empty() {
return nil, fmt.Errorf("trailing bytes after DigitallySigned")
}
return ds, nil
}