Skip to content

Commit

Permalink
ctclient: Use Cobra library for command-line tools (#901)
Browse files Browse the repository at this point in the history
This change refactors the ctclient tool to use the widely adopted Cobra library
for handling CLI flags and subcommands.

Before:

$ ./ctclient -h
Usage of ./ctclient:
  -align_getentries
        Enable get-entries request alignment (default true)
  -alsologtostderr
        log to standard error as well as files
  -cert_chain string
        Name of file containing certificate chain as concatenated PEM files
  -chain
        Display entire certificate chain
  -first int
        First entry to get (default -1)
  -last int
        Last entry to get (default -1)
  -leaf_hash string
        Leaf hash to retrieve (as hex string or base64)
  -log_backtrace_at value
        when logging hits line file:N, emit a stack trace
  -log_dir string
        If non-empty, write log files in this directory
  -log_list string
        Location of master log list (URL or filename) (default "https://www.gstatic.com/ct/log_list/all_logs_list.json")
  -log_mmd duration
        Log's maximum merge delay (default 24h0m0s)
  -log_name string
        Name of log to retrieve information from --log_list for
  -log_uri string
        CT log base URI (default "https://ct.googleapis.com/rocketeer")
  -logtostderr
        log to standard error instead of files
  -prev_hash string
        Previous tree hash to check against (as hex string or base64)
  -prev_size uint
        Previous tree size to get consistency against
  -pub_key string
        Name of file containing log's public key
  -size uint
        Tree size to query at
  -skip_https_verify
        Skip verification of HTTPS transport connection
  -stderrthreshold value
        logs at or above this threshold go to stderr
  -text
        Display certificates as text (default true)
  -timestamp int
        Timestamp to use for inclusion checking
  -tree_hash string
        Tree hash to check against (as hex string or base64)
  -v value
        log level for V logs
  -vmodule value
        comma-separated list of pattern=N settings for file-filtered logging


After:

$ ./ctclient --help
A command line client for Certificate Transparency logs

Usage:
  ctclient [command]

Available Commands:
  bisect                Find a log entry by timestamp
  completion            Generate the autocompletion script for the specified shell
  get-consistency-proof Fetch and verify a consistency proof between two tree states
  get-entries           Fetch a range of entries in the log
  get-inclusion-proof   Fetch and verify the inclusion proof for an entry
  get-roots             Fetch the root certificates accepted by the log
  get-sth               Fetch the latest STH of the log
  help                  Help about any command
  upload                Submit a certificate (pre-)chain to the log

Flags:
...
Use "ctclient [command] --help" for more information about a command.

$ ./ctclient getentries --help
Fetch a range of entries in the log

Usage:
  ctclient get-entries {--log_uri uri | --log_name name [--log_list {file|uri}]} [--pub_key file] --first=idx [--last=idx] [flags]

Aliases:
  get-entries, getentries, entries

Flags:
      --chain       Display entire certificate chain
      --first int   First entry to get (default -1)
  -h, --help        help for get-entries
      --last int    Last entry to get (default -1)
      --text        Display certificates as text (default true)

Global Flags:
      --alsologtostderr                  log to standard error as well as files
      --log_backtrace_at traceLocation   when logging hits line file:N, emit a stack trace (default :0)
      --log_dir string                   If non-empty, write log files in this directory
      --log_list string                  Location of master log list (URL or filename) (default "https://www.gstatic.com/ct/log_list/all_logs_list.json")
      --log_name string                  Name of log to retrieve information from --log_list for
      --log_uri string                   CT log base URI (default "https://ct.googleapis.com/rocketeer")
      --logtostderr                      log to standard error instead of files
      --pub_key string                   Name of file containing log's public key
      --skip_https_verify                Skip verification of HTTPS transport connection
      --stderrthreshold severity         logs at or above this threshold go to stderr (default 2)
  -v, --v Level                          log level for V logs
      --vmodule moduleSpec               comma-separated list of pattern=N settings for file-filter
  • Loading branch information
pav-kv authored Apr 25, 2022
1 parent 58784bd commit 5fcf774
Show file tree
Hide file tree
Showing 13 changed files with 873 additions and 541 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
* `ctfe.PEMCertPool` type has been moved to `x509util.PEMCertPool` to reduce
dependencies (#903).

### Misc
* `ctclient` tool now uses Cobra for better CLI experience (#901).

## v1.1.2

### CTFE
Expand Down
82 changes: 82 additions & 0 deletions client/ctclient/cmd/bisect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2022 Google LLC. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"fmt"
"sort"

"github.com/golang/glog"
ct "github.com/google/certificate-transparency-go"
"github.com/spf13/cobra"
)

func init() {
cmd := cobra.Command{
Use: fmt.Sprintf("bisect %s --timestamp=ts [--chain] [--text=false]", connectionFlags),
Aliases: []string{"find-timestamp"},
Short: "Find a log entry by timestamp",
Run: func(cmd *cobra.Command, _ []string) {
runBisect(cmd.Context())
},
}
cmd.Flags().Int64Var(&timestamp, "timestamp", 0, "Timestamp to use for inclusion checking")
// TODO(pavelkalinnikov): Don't share these parameters with get-entries.
cmd.Flags().BoolVar(&chainOut, "chain", false, "Display entire certificate chain")
cmd.Flags().BoolVar(&textOut, "text", true, "Display certificates as text")
rootCmd.AddCommand(&cmd)
}

// runBisect runs the bisect command.
func runBisect(ctx context.Context) {
logClient := connect(ctx)
if timestamp == 0 {
glog.Exit("No -timestamp option supplied")
}
target := timestamp
sth, err := logClient.GetSTH(ctx)
if err != nil {
exitWithDetails(err)
}
getEntry := func(idx int64) *ct.RawLogEntry {
entries, err := logClient.GetRawEntries(ctx, idx, idx)
if err != nil {
exitWithDetails(err)
}
if l := len(entries.Entries); l != 1 {
glog.Exitf("Unexpected number (%d) of entries received requesting index %d", l, idx)
}
logEntry, err := ct.RawLogEntryFromLeaf(idx, &entries.Entries[0])
if err != nil {
glog.Exitf("Failed to parse leaf %d: %v", idx, err)
}
return logEntry
}
// Performing a binary search assumes that the timestamps are monotonically
// increasing.
idx := sort.Search(int(sth.TreeSize), func(idx int) bool {
glog.V(1).Infof("check timestamp at index %d", idx)
entry := getEntry(int64(idx))
return entry.Leaf.TimestampedEntry.Timestamp >= uint64(target)
})
when := ct.TimestampToTime(uint64(target))
if idx >= int(sth.TreeSize) {
fmt.Printf("No entry with timestamp>=%d (%v) found up to tree size %d\n", target, when, sth.TreeSize)
return
}
fmt.Printf("First entry with timestamp>=%d (%v) found at index %d\n", target, when, idx)
showRawLogEntry(getEntry(int64(idx)))
}
114 changes: 114 additions & 0 deletions client/ctclient/cmd/get_consistency_proof.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2022 Google LLC. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"

"github.com/golang/glog"
"github.com/google/certificate-transparency-go/client"
"github.com/spf13/cobra"
"github.com/transparency-dev/merkle"
"github.com/transparency-dev/merkle/rfc6962"
)

var (
treeHash string
prevSize uint64
prevHash string
)

func init() {
cmd := cobra.Command{
Use: fmt.Sprintf("get-consistency-proof %s --size=N --tree_hash=hash --prev_size=N --prev_hash=hash", connectionFlags),
Aliases: []string{"getconsistencyproof", "consistency-proof", "consistency"},
Short: "Fetch and verify a consistency proof between two tree states",
Run: func(cmd *cobra.Command, _ []string) {
runGetConsistencyProof(cmd.Context())
},
}
// TODO(pavelkalinnikov): Don't share this parameter with get-inclusion-proof.
cmd.Flags().Uint64Var(&treeSize, "size", 0, "Tree size to query at")
cmd.Flags().StringVar(&treeHash, "tree_hash", "", "Tree hash to check against (as hex string or base64)")
cmd.Flags().Uint64Var(&prevSize, "prev_size", 0, "Previous tree size to get consistency against")
cmd.Flags().StringVar(&prevHash, "prev_hash", "", "Previous tree hash to check against (as hex string or base64)")
rootCmd.AddCommand(&cmd)
}

// runGetConsistencyProof runs the get-consistency-proof command.
func runGetConsistencyProof(ctx context.Context) {
logClient := connect(ctx)
if treeSize <= 0 {
glog.Exit("No valid --size supplied")
}
if prevSize <= 0 {
glog.Exit("No valid --prev_size supplied")
}
var hash1, hash2 []byte
if prevHash != "" {
var err error
hash1, err = hashFromString(prevHash)
if err != nil {
glog.Exitf("Invalid --prev_hash: %v", err)
}
}
if treeHash != "" {
var err error
hash2, err = hashFromString(treeHash)
if err != nil {
glog.Exitf("Invalid --tree_hash: %v", err)
}
}
if (hash1 != nil) != (hash2 != nil) {
glog.Exitf("Need both --prev_hash and --tree_hash or neither")
}
getConsistencyProofBetween(ctx, logClient, prevSize, treeSize, hash1, hash2)
}

func getConsistencyProofBetween(ctx context.Context, logClient client.CheckLogClient, first, second uint64, prevHash, treeHash []byte) {
proof, err := logClient.GetSTHConsistency(ctx, uint64(first), uint64(second))
if err != nil {
exitWithDetails(err)
}
fmt.Printf("Consistency proof from size %d to size %d:\n", first, second)
for _, e := range proof {
fmt.Printf(" %x\n", e)
}
if prevHash == nil || treeHash == nil {
return
}
// We have tree hashes so we can verify the proof.
verifier := merkle.NewLogVerifier(rfc6962.DefaultHasher)
if err := verifier.VerifyConsistency(first, second, prevHash, treeHash, proof); err != nil {
glog.Exitf("Failed to VerifyConsistencyProof(%x @size=%d, %x @size=%d): %v", prevHash, first, treeHash, second, err)
}
fmt.Printf("Verified that hash %x @%d + proof = hash %x @%d\n", prevHash, first, treeHash, second)
}

func hashFromString(input string) ([]byte, error) {
hash, err := hex.DecodeString(input)
if err == nil && len(hash) == sha256.Size {
return hash, nil
}
hash, err = base64.StdEncoding.DecodeString(input)
if err == nil && len(hash) == sha256.Size {
return hash, nil
}
return nil, fmt.Errorf("hash value %q failed to parse as 32-byte hex or base64", input)
}
127 changes: 127 additions & 0 deletions client/ctclient/cmd/get_entries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2022 Google LLC. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"encoding/pem"
"fmt"
"os"

"github.com/golang/glog"
ct "github.com/google/certificate-transparency-go"
"github.com/google/certificate-transparency-go/x509"
"github.com/google/certificate-transparency-go/x509util"
"github.com/spf13/cobra"
)

var (
getFirst int64
getLast int64
chainOut bool
textOut bool
)

func init() {
cmd := cobra.Command{
Use: fmt.Sprintf("get-entries %s --first=idx [--last=idx]", connectionFlags),
Aliases: []string{"getentries", "entries"},
Short: "Fetch a range of entries in the log",
Run: func(cmd *cobra.Command, _ []string) {
runGetEntries(cmd.Context())
},
}
cmd.Flags().Int64Var(&getFirst, "first", -1, "First entry to get")
cmd.Flags().Int64Var(&getLast, "last", -1, "Last entry to get")
cmd.Flags().BoolVar(&chainOut, "chain", false, "Display entire certificate chain")
cmd.Flags().BoolVar(&textOut, "text", true, "Display certificates as text")
rootCmd.AddCommand(&cmd)
}

// runGetEntries runs the get-entries command.
func runGetEntries(ctx context.Context) {
logClient := connect(ctx)
if getFirst == -1 {
glog.Exit("No -first option supplied")
}
if getLast == -1 {
getLast = getFirst
}
rsp, err := logClient.GetRawEntries(ctx, getFirst, getLast)
if err != nil {
exitWithDetails(err)
}

for i, rawEntry := range rsp.Entries {
index := getFirst + int64(i)
rle, err := ct.RawLogEntryFromLeaf(index, &rawEntry)
if err != nil {
fmt.Printf("Index=%d Failed to unmarshal leaf entry: %v", index, err)
continue
}
showRawLogEntry(rle)
}
}

func showRawLogEntry(rle *ct.RawLogEntry) {
ts := rle.Leaf.TimestampedEntry
when := ct.TimestampToTime(ts.Timestamp)
fmt.Printf("Index=%d Timestamp=%d (%v) ", rle.Index, ts.Timestamp, when)

switch ts.EntryType {
case ct.X509LogEntryType:
fmt.Printf("X.509 certificate:\n")
showRawCert(*ts.X509Entry)
case ct.PrecertLogEntryType:
fmt.Printf("pre-certificate from issuer with keyhash %x:\n", ts.PrecertEntry.IssuerKeyHash)
showRawCert(rle.Cert) // As-submitted: with signature and poison.
default:
fmt.Printf("Unhandled log entry type %d\n", ts.EntryType)
}
if chainOut {
for _, c := range rle.Chain {
showRawCert(c)
}
}
}

func showRawCert(cert ct.ASN1Cert) {
if textOut {
c, err := x509.ParseCertificate(cert.Data)
if err != nil {
glog.Errorf("Error parsing certificate: %q", err.Error())
}
if c == nil {
return
}
showParsedCert(c)
} else {
showPEMData(cert.Data)
}
}

func showParsedCert(cert *x509.Certificate) {
if textOut {
fmt.Printf("%s\n", x509util.CertificateToString(cert))
} else {
showPEMData(cert.Raw)
}
}

func showPEMData(data []byte) {
if err := pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: data}); err != nil {
glog.Errorf("Failed to PEM encode cert: %q", err.Error())
}
}
Loading

0 comments on commit 5fcf774

Please sign in to comment.