272 lines
9.4 KiB
Go
272 lines
9.4 KiB
Go
// 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/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"slices"
|
||
|
|
|
||
|
|
"software.sslmate.com/src/certspotter/cttypes"
|
||
|
|
"software.sslmate.com/src/certspotter/merkletree"
|
||
|
|
)
|
||
|
|
|
||
|
|
type RFC6962Log struct {
|
||
|
|
URL *url.URL
|
||
|
|
HTTPClient *http.Client // nil to use default client
|
||
|
|
}
|
||
|
|
|
||
|
|
type RFC6962LogEntry struct {
|
||
|
|
Leaf_input []byte `json:"leaf_input"`
|
||
|
|
Extra_data []byte `json:"extra_data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ctlog *RFC6962Log) AddChain(ctx context.Context, chain [][]byte) (*cttypes.SignedCertificateTimestamp, error) {
|
||
|
|
return addChainOrPreChain(ctx, ctlog.HTTPClient, ctlog.URL, false, chain)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ctlog *RFC6962Log) AddPreChain(ctx context.Context, chain [][]byte) (*cttypes.SignedCertificateTimestamp, error) {
|
||
|
|
return addChainOrPreChain(ctx, ctlog.HTTPClient, ctlog.URL, true, chain)
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
fullURL := ctlog.URL.JoinPath("/ct/v1/get-entries").String()
|
||
|
|
fullURL += fmt.Sprintf("?start=%d&end=%d", startInclusive, endInclusive)
|
||
|
|
|
||
|
|
body, err := get(ctx, ctlog.HTTPClient, fullURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer body.Close()
|
||
|
|
|
||
|
|
limitedBody := io.LimitReader(body, maxResponseBytes)
|
||
|
|
dec := json.NewDecoder(limitedBody)
|
||
|
|
if tok, err := dec.Token(); err != nil {
|
||
|
|
return nil, fmt.Errorf("Get %q: error reading JSON object token: %w", fullURL, err)
|
||
|
|
} else if tok != json.Delim('{') {
|
||
|
|
return nil, fmt.Errorf("Get %q: not a JSON object", fullURL)
|
||
|
|
}
|
||
|
|
for {
|
||
|
|
if !dec.More() {
|
||
|
|
return nil, fmt.Errorf("Get %q: JSON object missing entries", fullURL)
|
||
|
|
}
|
||
|
|
tok, err := dec.Token()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("Get %q: error reading JSON field name: %w", fullURL, err)
|
||
|
|
}
|
||
|
|
if tok == "entries" {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
var rawMessage json.RawMessage
|
||
|
|
if err := dec.Decode(&rawMessage); err != nil {
|
||
|
|
return nil, fmt.Errorf("Get %q: error skipping unwanted JSON field: %w", fullURL, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if tok, err := dec.Token(); err != nil {
|
||
|
|
return nil, fmt.Errorf("Get %q: error reading JSON array token: %w", fullURL, err)
|
||
|
|
} else if tok != json.Delim('[') {
|
||
|
|
return nil, fmt.Errorf("Get %q: entries is not a JSON array", fullURL)
|
||
|
|
}
|
||
|
|
|
||
|
|
var entries []RFC6962LogEntry
|
||
|
|
for dec.More() {
|
||
|
|
if uint64(len(entries)) == endInclusive-startInclusive+1 {
|
||
|
|
return entries, fmt.Errorf("Get %q: extraneous entries returned", fullURL)
|
||
|
|
}
|
||
|
|
var entry RFC6962LogEntry
|
||
|
|
if err := dec.Decode(&entry); err != nil {
|
||
|
|
return entries, fmt.Errorf("Get %q: error reading JSON entry: %w", fullURL, err)
|
||
|
|
}
|
||
|
|
entries = append(entries, entry)
|
||
|
|
}
|
||
|
|
if tok, err := dec.Token(); err != nil {
|
||
|
|
return entries, fmt.Errorf("Get %q: error reading end of JSON array: %w", fullURL, err)
|
||
|
|
} else if tok != json.Delim(']') {
|
||
|
|
return entries, fmt.Errorf("Get %q: entries array not terminated", fullURL)
|
||
|
|
}
|
||
|
|
if len(entries) == 0 {
|
||
|
|
return entries, fmt.Errorf("Get %q: zero entries returned", fullURL)
|
||
|
|
}
|
||
|
|
io.Copy(io.Discard, limitedBody) // So the HTTP connection can be reused
|
||
|
|
return entries, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ctlog *RFC6962Log) GetEntries(ctx context.Context, startInclusive uint64, endInclusive uint64) ([]Entry, error) {
|
||
|
|
nativeEntries, err := ctlog.getEntries(ctx, startInclusive, endInclusive)
|
||
|
|
var entries []Entry
|
||
|
|
if nativeEntries != nil {
|
||
|
|
entries = make([]Entry, len(nativeEntries))
|
||
|
|
for i := range nativeEntries {
|
||
|
|
entries[i] = &nativeEntries[i]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return entries, err
|
||
|
|
}
|
||
|
|
|
||
|
|
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() (cttypes.ASN1Cert, 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")
|
||
|
|
}
|
||
|
|
}
|