Add ctclient, ctcrypto, cttypes, tlstypes packages
This commit is contained in:
parent
3a609ea037
commit
13837fde04
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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[:])
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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
9
go.mod
|
@ -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
14
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue