From 26cd65d9bd8250b8b18b00e4ffae8f964fb99e66 Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Thu, 9 Jan 2020 22:56:33 -0800 Subject: [PATCH] refactor certigo package main This splits the logic into a cli, which handles parsing command line args and reading files, and a terminal abstraction for handling user input and ouput. All uses of os.Exit are removed in favor of returning errors. Overall this enables better testing and reuse of code. Previously we had to rely on external unit testing for CLI tests, which are harder to write tests. --- cli/cli.go | 231 +++++++++++++++++++++++++++++++++++ cli/cli_test.go | 94 ++++++++++++++ cli/terminal/terminal.go | 97 +++++++++++++++ cli/terminal/testing.go | 39 ++++++ go.mod | 3 +- lib/certs.go | 63 +++++++--- lib/verify.go | 24 ++-- main.go | 257 +-------------------------------------- starttls/starttls.go | 2 +- 9 files changed, 525 insertions(+), 285 deletions(-) create mode 100644 cli/cli.go create mode 100644 cli/cli_test.go create mode 100644 cli/terminal/terminal.go create mode 100644 cli/terminal/testing.go diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..a923d95 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,231 @@ +package cli + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "github.com/square/certigo/cli/terminal" + "github.com/square/certigo/lib" + "github.com/square/certigo/starttls" + "gopkg.in/alecthomas/kingpin.v2" + "os" + "strings" +) + +var ( + app = kingpin.New("certigo", "A command-line utility to examine and validate certificates to help with debugging SSL/TLS issues.") + verbose = app.Flag("verbose", "Print verbose").Short('v').Bool() + + dump = app.Command("dump", "Display information about a certificate from a file or stdin.") + dumpFiles = dump.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFiles() + dumpType = dump.Flag("format", "Format of given input (PEM, DER, JCEKS, PKCS12; heuristic if missing).").Short('f').String() + dumpPassword = dump.Flag("password", "Password for PKCS12/JCEKS key stores (reads from TTY if missing).").Short('p').String() + dumpPem = dump.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool() + dumpJSON = dump.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool() + + connect = app.Command("connect", "Connect to a server and print its certificate(s).") + connectTo = connect.Arg("server[:port]", "Hostname or IP to connect to, with optional port.").Required().String() + connectName = connect.Flag("name", "Override the server name used for Server Name Indication (SNI).").Short('n').String() + connectCaPath = connect.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile() + connectCert = connect.Flag("cert", "Client certificate chain for connecting to server (PEM).").ExistingFile() + connectKey = connect.Flag("key", "Private key for client certificate, if not in same file (PEM).").ExistingFile() + connectStartTLS = connect.Flag("start-tls", fmt.Sprintf("Enable StartTLS protocol; one of: %v.", starttls.Protocols)).Short('t').PlaceHolder("PROTOCOL").Enum(starttls.Protocols...) + connectIdentity = connect.Flag("identity", "With --start-tls, sets the DB user or SMTP EHLO name").Default("certigo").String() + connectProxy = connect.Flag("proxy", "Optional URI for HTTP(s) CONNECT proxy to dial connections with").URL() + connectTimeout = connect.Flag("timeout", "Timeout for connecting to remote server (can be '5m', '1s', etc).").Default("5s").Duration() + connectPem = connect.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool() + connectJSON = connect.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool() + connectVerify = connect.Flag("verify", "Verify certificate chain.").Bool() + + verify = app.Command("verify", "Verify a certificate chain from file/stdin against a name.") + verifyFile = verify.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFile() + verifyType = verify.Flag("format", "Format of given input (PEM, DER, JCEKS, PKCS12; heuristic if missing).").Short('f').String() + verifyPassword = verify.Flag("password", "Password for PKCS12/JCEKS key stores (reads from TTY if missing).").Short('p').String() + verifyName = verify.Flag("name", "Server name to verify certificate against.").Short('n').Required().String() + verifyCaPath = verify.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile() + verifyJSON = verify.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool() +) + +func Run(args []string, tty terminal.Terminal) int { + terminalWidth := tty.DetermineWidth() + stdout := tty.Output() + errOut := tty.Error() + + printErr := func(format string, args ...interface{}) int { + _, err := fmt.Fprintf(errOut, format, args...) + if err != nil { + // If we can't write the error, we bail with a different return code... not much good + // we can do at this point + return 3 + } + return 2 + } + app.Version("1.11.0") + + // Alias starttls to start-tls + connect.Flag("starttls", "").Hidden().EnumVar(connectStartTLS, starttls.Protocols...) + // Use long help because many useful flags are under subcommands + app.UsageTemplate(kingpin.LongHelpTemplate) + + result := lib.SimpleResult{} + command, err := app.Parse(args) + if err != nil { + return printErr("%s, try --help\n", err) + } + switch command { + case dump.FullCommand(): // Dump certificate + if dumpPassword != nil && *dumpPassword != "" { + tty.SetDefaultPassword(*dumpPassword) + } + + files, err := inputFiles(*dumpFiles) + defer func() { + for _, file := range files { + file.Close() + } + }() + + if *dumpPem { + err = lib.ReadAsPEMFromFiles(files, *dumpType, tty.ReadPassword, func(block *pem.Block) error { + block.Headers = nil + return pem.Encode(stdout, block) + }) + } else { + err = lib.ReadAsX509FromFiles(files, *dumpType, tty.ReadPassword, func(cert *x509.Certificate, err error) error { + if err != nil { + return fmt.Errorf("error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n")) + } else { + result.Certificates = append(result.Certificates, cert) + } + return nil + }) + + if *dumpJSON { + blob, _ := json.Marshal(result) + fmt.Println(string(blob)) + } else { + for i, cert := range result.Certificates { + fmt.Fprintf(stdout, "** CERTIFICATE %d **\n", i+1) + fmt.Fprintf(stdout, "%s\n\n", lib.EncodeX509ToText(cert, terminalWidth, *verbose)) + } + } + } + if err != nil { + return printErr("error: %s\n", strings.TrimSuffix(err.Error(), "\n")) + } else if len(result.Certificates) == 0 && !*dumpPem { + printErr("warning: no certificates found in input\n") + } + + case connect.FullCommand(): // Get certs by connecting to a server + if connectStartTLS == nil && connectIdentity != nil { + return printErr("error: --identity can only be used with --start-tls") + } + connState, cri, err := starttls.GetConnectionState( + *connectStartTLS, *connectName, *connectTo, *connectIdentity, + *connectCert, *connectKey, *connectProxy, *connectTimeout) + if err != nil { + return printErr("%s\n", strings.TrimSuffix(err.Error(), "\n")) + } + result.TLSConnectionState = connState + result.CertificateRequestInfo = cri + for _, cert := range connState.PeerCertificates { + if *connectPem { + pem.Encode(stdout, lib.EncodeX509ToPEM(cert, nil)) + } else { + result.Certificates = append(result.Certificates, cert) + } + } + + var hostname string + if *connectName != "" { + hostname = *connectName + } else { + hostname = strings.Split(*connectTo, ":")[0] + } + verifyResult := lib.VerifyChain(connState.PeerCertificates, connState.OCSPResponse, hostname, *connectCaPath) + result.VerifyResult = &verifyResult + + if *connectJSON { + blob, _ := json.Marshal(result) + fmt.Println(string(blob)) + } else if !*connectPem { + fmt.Fprintf( + stdout, "%s\n\n", + lib.EncodeTLSInfoToText(result.TLSConnectionState, result.CertificateRequestInfo)) + + for i, cert := range result.Certificates { + fmt.Fprintf(stdout, "** CERTIFICATE %d **\n", i+1) + fmt.Fprintf(stdout, "%s\n\n", lib.EncodeX509ToText(cert, terminalWidth, *verbose)) + } + lib.PrintVerifyResult(stdout, *result.VerifyResult) + } + + if *connectVerify && len(result.VerifyResult.Error) > 0 { + return 1 + } + case verify.FullCommand(): + if verifyPassword != nil && *verifyPassword != "" { + tty.SetDefaultPassword(*verifyPassword) + } + + file, err := inputFile(*verifyFile) + if err != nil { + return printErr("%s\n", err.Error()) + } + defer file.Close() + + chain := []*x509.Certificate{} + err = lib.ReadAsX509FromFiles([]*os.File{file}, *verifyType, tty.ReadPassword, func(cert *x509.Certificate, err error) error { + if err != nil { + return err + } else { + chain = append(chain, cert) + } + return nil + }) + if err != nil { + return printErr("error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n")) + } + + verifyResult := lib.VerifyChain(chain, nil, *verifyName, *verifyCaPath) + if *verifyJSON { + blob, _ := json.Marshal(verifyResult) + fmt.Println(string(blob)) + } else { + lib.PrintVerifyResult(stdout, verifyResult) + } + if verifyResult.Error != "" { + return 1 + } + } + return 0 +} + +func inputFile(fileName string) (*os.File, error) { + if fileName == "" { + return os.Stdin, nil + } + + rawFile, err := os.Open(fileName) + if err != nil { + return nil, fmt.Errorf("unable to open file: %s\n", err) + } + return rawFile, nil +} + +func inputFiles(fileNames []string) ([]*os.File, error) { + var files []*os.File + if fileNames != nil { + for _, filename := range fileNames { + rawFile, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("unable to open file: %s\n", err) + } + files = append(files, rawFile) + } + } else { + files = append(files, os.Stdin) + } + return files, nil +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..f4b7d98 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/square/certigo/cli/terminal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testCert string = ` +-----BEGIN CERTIFICATE----- +MIIE1DCCArygAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwPzELMAkGA1UEBhMCVVMx +FzAVBgNVBAoMDnRlc3QxLmFjbWUuY29tMRcwFQYDVQQDDA5JbnRlcm1lZGlhZXRD +QTAeFw0xNzA3MTkxNjUwMjBaFw0xNzA3MjkxNjUwMjBaMDUxCzAJBgNVBAYTAlVT +MRcwFQYDVQQKDA50ZXN0MS5hY21lLmNvbTENMAsGA1UEAwwEYmxvZzCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKm8P47lABp4+rz2nN+QYrxedbaFVWoF +FuoSkqcHsafMwbMrN+kI6wJVtlbwviDvxWFJ92q0H71QNFybTsmof3KUN/kYCp7P ++LKhBrN0ttWI5q6v5eDrjN0VdtVdnlZOYmJFbvETOgfK/qXKNRRM8HYW0tdqrtEw +CR5dIu53xVUSViBdwXpuy2c5W2mFn1gxTpdW+3hbZsL1pHrU9qPWLtTgl/KY8kjs +I7KW1cIcinE4SJomhB5L/4emhxKGY+kEa2+fN9IPjjvKSMOw9kiBKk1GHZcIY5EA +O3TIfUk3fysPzi5qA0su/bNtPQy1uXgXS10xUlV7pqRPvHjiNzgFkXUCAwEAAaOB +4zCB4DAJBgNVHRMEAjAAMB0GA1UdDgQWBBRVQ91jSOONzVr1VGBdJOlPN+3XxTBg +BgNVHSMEWTBXgBQ13bfx50rDZO3y2CZdHPgleFUEoKE7pDkwNzELMAkGA1UEBhMC +VVMxFzAVBgNVBAoMDnRlc3QxLmFjbWUuY29tMQ8wDQYDVQQDDAZSb290Q0GCAhAA +MA4GA1UdDwEB/wQEAwIDqDATBgNVHSUEDDAKBggrBgEFBQcDATAtBgNVHREEJjAk +hiJzcGlmZmU6Ly9kZXYuYWNtZS5jb20vcGF0aC9zZXJ2aWNlMA0GCSqGSIb3DQEB +CwUAA4ICAQBp2+rtUxt1VmNM/vi6PwoSoYzWFmQ2nc4OM7bsOG4uppU54wRYZ+T7 +c42EcrpyBgWn+rWHT1Hi6SNcmloKHydaUTZ4pq3IlKKnBNqwivU5BzIxYLDrhR/U +wd9s1tgmLvADqkQa1XjjSFn5Auoj1R640ry4qpw8IOusdm6wVhru4ssRnHX4E2uR +jQe7b3ws38aZhjtL78Ip0BB4yPxWJRp/WmEoT33QP+cZhA4IYWECxNODr6DSJeq2 +VNu/6JACGrNfM2Sjt4Wxz+nIa3cKDNCA6PR8StTUTcoQ6ZBzpn+n/Q1xSRIOJz6N +hgfkyb9O7HAMdAP+TxehjqG3gh5Ky2DgYMCIZOztVzsuOb1DGJe/kGUKeRJLl2/O +QwkctwUOcVIxckNu6OvclriFzvoXObqO77XeCI2V1Vef0wGTWlWNOdbFa4708Y7f +5UdwInYQUi87RFDnc1SDU4Jrsv4KzZiv9FCfDg8pCBIdWpWT7DAuI0d7i7PZ+iFt +ZZ6sb/YDkyiDXU4ar/dja0FDE2r7jsN9D+FfW49+iDvXr4ELQyhZpW3Zr1Ojwm58 +CJzjZwbRYiVwPBRsKmiYfO1E7esvw3CmjK5chfz8c40f6/APDro9ZmYNBRv2CnJy +t/DtcM/GpAhBbLP9Tk7kPB41v5fRIxVDo50Iz/qvkr37pQ4RsejSFg== +-----END CERTIFICATE----- +` + +const expectedVerbose string = `** CERTIFICATE 1 ** +Serial: 4096 +Valid: 2017-07-19 16:50 UTC to 2017-07-29 16:50 UTC +Signature: SHA256-RSA +Subject Info: + Country: US + Organization: test1.acme.com + CommonName: blog +Issuer Info: + Country: US + Organization: test1.acme.com + CommonName: IntermediaetCA +Subject Key ID: 55:43:DD:63:48:E3:8D:CD:5A:F5:54:60:5D:24:E9:4F:37:ED:D7:C5 +Authority Key ID: 35:DD:B7:F1:E7:4A:C3:64:ED:F2:D8:26:5D:1C:F8:25:78:55:04:A0 +Basic Constraints: CA:false +Key Usage: + Digital Signature + Key Encipherment + Key Agreement +Extended Key Usage: + Server Auth +URI Names: + spiffe://dev.acme.com/path/service + +` + +// Test basic dump functionality: Dump a cert +func TestDump(t *testing.T) { + tmpfile, err := ioutil.TempFile("", t.Name()) + require.NoError(t, err) + defer os.Remove(tmpfile.Name()) + + _, err = tmpfile.Write([]byte(testCert)) + require.NoError(t, err) + + args := []string{"dump", "--verbose", "--format", "PEM", tmpfile.Name()} + testTerminal := terminal.TestTerminal{Width: 80} + + assert.EqualValues(t, 0, Run(args, &testTerminal), "process should exit 0") + assert.Empty(t, testTerminal.ErrorBuf.Bytes(), "no error output expected") + assert.EqualValues(t, expectedVerbose, testTerminal.OutputBuf.String()) +} + +func TestDumpMissingFile(t *testing.T) { + testTerminal := terminal.TestTerminal{Width: 80} + args := []string{"dump", "this-is-a-file-that-definitely-does-not-exist1111.pem"} + assert.EqualValues(t, 2, Run(args, &testTerminal), "process should exit 0") + const expected = "path 'this-is-a-file-that-definitely-does-not-exist1111.pem' does not exist, try --help\n" + assert.Equal(t, expected, testTerminal.ErrorBuf.String()) + assert.Empty(t, testTerminal.OutputBuf.Bytes()) +} diff --git a/cli/terminal/terminal.go b/cli/terminal/terminal.go new file mode 100644 index 0000000..9e512b1 --- /dev/null +++ b/cli/terminal/terminal.go @@ -0,0 +1,97 @@ +package terminal + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-colorable" + "golang.org/x/crypto/ssh/terminal" +) + +const minWidth = 60 +const maxWidth = 80 + +// Terminal handles interacting with the user in Certigo +type Terminal interface { + Output() io.Writer + Error() io.Writer + SetDefaultPassword(password string) + ReadPassword(prompt string) string + DetermineWidth() int +} + +// TTY represents unixish stdio, possibly with /dev/tty used to read user input +type TTY struct { + defaultPassword *string +} + +func OpenTTY() *TTY { + return &TTY{} +} + +func (t *TTY) Output() io.Writer { + return colorable.NewColorableStdout() +} + +func (t *TTY) Error() io.Writer { + return os.Stderr +} + +func (t *TTY) SetDefaultPassword(password string) { + t.defaultPassword = &password +} + +func (t *TTY) ReadPassword(prompt string) string { + if t.defaultPassword != nil { + return *t.defaultPassword + } + + var tty *os.File + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + tty = os.Stdin + } else { + defer tty.Close() + } + + tty.WriteString("Enter password") + if prompt != "" { + tty.WriteString(fmt.Sprintf(" for entry [%s]", prompt)) + } + tty.WriteString(": ") + + password, err := terminal.ReadPassword(int(tty.Fd())) + tty.WriteString("\n") + if err != nil { + fmt.Fprintf(os.Stderr, "error reading password: %s\n", err) + os.Exit(1) + } + + return strings.TrimSuffix(string(password), "\n") +} + +func (t *TTY) DetermineWidth() int { + var width int + fd := int(os.Stdout.Fd()) + if terminal.IsTerminal(fd) { + var err error + width, _, err = terminal.GetSize(fd) + if err != nil { + width = minWidth + } + } else { + width = minWidth + } + + if width > maxWidth { + width = maxWidth + } else if width < minWidth { + width = minWidth + } + return width +} + +// Assert TTY implements terminal +var _ Terminal = &TTY{} diff --git a/cli/terminal/testing.go b/cli/terminal/testing.go new file mode 100644 index 0000000..d4e79b3 --- /dev/null +++ b/cli/terminal/testing.go @@ -0,0 +1,39 @@ +package terminal + +import ( + "bytes" + "io" + + "github.com/mattn/go-colorable" +) + +// TestTerminal just collects input into buffers +// That can be used to check output in tests +type TestTerminal struct { + OutputBuf bytes.Buffer + ErrorBuf bytes.Buffer + Password string + Width int +} + +var _ Terminal = &TestTerminal{} + +func (t *TestTerminal) Output() io.Writer { + return colorable.NewNonColorable(&t.OutputBuf) +} + +func (t *TestTerminal) Error() io.Writer { + return &t.ErrorBuf +} + +func (t *TestTerminal) SetDefaultPassword(password string) { + t.Password = password +} + +func (t *TestTerminal) ReadPassword(prompt string) string { + return t.Password +} + +func (t TestTerminal) DetermineWidth() int { + return t.Width +} diff --git a/go.mod b/go.mod index be10950..725505c 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,11 @@ require ( github.com/huandu/xstrings v1.2.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/mattn/go-colorable v0.1.4 + github.com/mattn/go-isatty v0.0.11 github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mwitkow/go-http-dialer v0.0.0-20161116154839-378f744fb2b8 github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.2.2 // indirect + github.com/stretchr/testify v1.2.2 golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 diff --git a/lib/certs.go b/lib/certs.go index 0bc70bf..a74b3a9 100644 --- a/lib/certs.go +++ b/lib/certs.go @@ -88,8 +88,8 @@ func errorFromErrors(errs []error) error { // data may be in plain-text PEM files, DER-encoded certificates or PKCS7 // envelopes, or PKCS12/JCEKS keystores. All inputs will be converted to PEM // blocks and passed to the callback. -func ReadAsPEMFromFiles(files []*os.File, format string, password func(string) string, callback func(*pem.Block)) error { - errs := []error{} +func ReadAsPEMFromFiles(files []*os.File, format string, password func(string) string, callback func(*pem.Block) error) error { + var errs []error for _, file := range files { reader := bufio.NewReaderSize(file, 4) format, err := formatForFile(reader, file.Name(), format) @@ -109,7 +109,7 @@ func ReadAsPEMFromFiles(files []*os.File, format string, password func(string) s // be in plain-text PEM files, DER-encoded certificates or PKCS7 envelopes, or // PKCS12/JCEKS keystores. All inputs will be converted to PEM blocks and // passed to the callback. -func ReadAsPEM(readers []io.Reader, format string, password func(string) string, callback func(*pem.Block)) error { +func ReadAsPEM(readers []io.Reader, format string, password func(string) string, callback func(*pem.Block) error) error { errs := []error{} for _, r := range readers { reader := bufio.NewReaderSize(r, 4) @@ -130,7 +130,7 @@ func ReadAsPEM(readers []io.Reader, format string, password func(string) string, // inputs. Input data may be in plain-text PEM files, DER-encoded certificates // or PKCS7 envelopes, or PKCS12/JCEKS keystores. All inputs will be converted // to X.509 certificates (private keys are skipped) and passed to the callback. -func ReadAsX509FromFiles(files []*os.File, format string, password func(string) string, callback func(*x509.Certificate, error)) error { +func ReadAsX509FromFiles(files []*os.File, format string, password func(string) string, callback func(*x509.Certificate, error) error) error { errs := []error{} for _, file := range files { reader := bufio.NewReaderSize(file, 4) @@ -151,7 +151,7 @@ func ReadAsX509FromFiles(files []*os.File, format string, password func(string) // data may be in plain-text PEM files, DER-encoded certificates or PKCS7 // envelopes, or PKCS12/JCEKS keystores. All inputs will be converted to X.509 // certificates (private keys are skipped) and passed to the callback. -func ReadAsX509(readers []io.Reader, format string, password func(string) string, callback func(*x509.Certificate, error)) error { +func ReadAsX509(readers []io.Reader, format string, password func(string) string, callback func(*x509.Certificate, error) error) error { errs := []error{} for _, r := range readers { reader := bufio.NewReaderSize(r, 4) @@ -168,27 +168,28 @@ func ReadAsX509(readers []io.Reader, format string, password func(string) string return errorFromErrors(errs) } -func pemToX509(callback func(*x509.Certificate, error)) func(*pem.Block) { - return func(block *pem.Block) { +func pemToX509(callback func(*x509.Certificate, error) error) func(*pem.Block) error { + return func(block *pem.Block) error { switch block.Type { case "CERTIFICATE": cert, err := x509.ParseCertificate(block.Bytes) - callback(cert, err) + return callback(cert, err) case "PKCS7": certs, err := pkcs7.ExtractCertificates(block.Bytes) if err == nil { for _, cert := range certs { - callback(cert, nil) + return callback(cert, nil) } } else { - callback(nil, err) + return callback(nil, err) } } + return nil } } // readCertsFromStream takes some input and converts it to PEM blocks. -func readCertsFromStream(reader io.Reader, filename string, format string, password func(string) string, callback func(*pem.Block)) error { +func readCertsFromStream(reader io.Reader, filename string, format string, password func(string) string, callback func(*pem.Block) error) error { headers := map[string]string{} if filename != "" && filename != os.Stdin.Name() { headers[fileHeader] = filename @@ -200,7 +201,10 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw for scanner.Scan() { block, _ := pem.Decode(scanner.Bytes()) block.Headers = mergeHeaders(block.Headers, headers) - callback(block) + err := callback(block) + if err != nil { + return err + } } return nil case "DER": @@ -211,14 +215,20 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw x509Certs, err0 := x509.ParseCertificates(data) if err0 == nil { for _, cert := range x509Certs { - callback(EncodeX509ToPEM(cert, headers)) + err := callback(EncodeX509ToPEM(cert, headers)) + if err != nil { + return err + } } return nil } p7bBlocks, err1 := pkcs7.ParseSignedData(data) if err1 == nil { for _, block := range p7bBlocks { - callback(pkcs7ToPem(block, headers)) + err := callback(pkcs7ToPem(block, headers)) + if err != nil { + return err + } } return nil } @@ -234,7 +244,10 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw } for _, block := range blocks { block.Headers = mergeHeaders(block.Headers, headers) - callback(block) + err := callback(block) + if err != nil { + return err + } } return nil case "JCEKS": @@ -244,20 +257,32 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw } for _, alias := range keyStore.ListCerts() { cert, _ := keyStore.GetCert(alias) - callback(EncodeX509ToPEM(cert, mergeHeaders(headers, map[string]string{nameHeader: alias}))) + err := callback(EncodeX509ToPEM(cert, mergeHeaders(headers, map[string]string{nameHeader: alias}))) + if err != nil { + return err + } } for _, alias := range keyStore.ListPrivateKeys() { key, certs, err := keyStore.GetPrivateKeyAndCerts(alias, []byte(password(alias))) if err != nil { return fmt.Errorf("unable to parse keystore: %s\n", err) } - block, err := keyToPem(key, mergeHeaders(headers, map[string]string{nameHeader: alias})) + + mergedHeaders := mergeHeaders(headers, map[string]string{nameHeader: alias}) + + block, err := keyToPem(key, mergedHeaders) if err != nil { return fmt.Errorf("problem reading key: %s\n", err) } - callback(block) + + if err := callback(block); err != nil { + return err + } + for _, cert := range certs { - callback(EncodeX509ToPEM(cert, mergeHeaders(headers, map[string]string{nameHeader: alias}))) + if err = callback(EncodeX509ToPEM(cert, mergedHeaders)); err != nil { + return err + } } } return nil diff --git a/lib/verify.go b/lib/verify.go index 0a33303..651fb0f 100644 --- a/lib/verify.go +++ b/lib/verify.go @@ -76,15 +76,14 @@ func (s SimpleResult) MarshalJSON() ([]byte, error) { return json.Marshal(out) } -func caBundle(caPath string) *x509.CertPool { +func caBundle(caPath string) (*x509.CertPool, error) { if caPath == "" { - return nil + return nil, nil } caFile, err := os.Open(caPath) if err != nil { - fmt.Fprintf(os.Stderr, "error opening CA bundle %s: %s\n", caPath, err) - os.Exit(1) + return nil, fmt.Errorf("error opening CA bundle %s: %s\n", caPath, err) } bundle := x509.NewCertPool() @@ -95,18 +94,18 @@ func caBundle(caPath string) *x509.CertPool { // TODO: The JDK trust store ships with this password. return "changeit" }, - func(cert *x509.Certificate, err error) { + func(cert *x509.Certificate, err error) error { if err != nil { - fmt.Fprintf(os.Stderr, "error parsing CA bundle: %s\n", err) + return fmt.Errorf("error parsing CA bundle: %s\n", err) } else { bundle.AddCert(cert) } + return nil }) if err != nil { - fmt.Fprintf(os.Stderr, "error parsing CA bundle: %s\n", err) - os.Exit(1) + return nil, fmt.Errorf("error parsing CA bundle: %s\n", err) } - return bundle + return bundle, nil } func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath string) SimpleVerification { @@ -120,9 +119,14 @@ func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath s intermediates.AddCert(certs[i]) } + roots, err := caBundle(caPath) + if err != nil { + result.Error = fmt.Sprintf("%s", err) + return result + } opts := x509.VerifyOptions{ DNSName: dnsName, - Roots: caBundle(caPath), + Roots: roots, Intermediates: intermediates, } diff --git a/main.go b/main.go index 35aa459..cb8cf39 100644 --- a/main.go +++ b/main.go @@ -17,263 +17,12 @@ package main import ( - "crypto/x509" - "encoding/json" - "encoding/pem" - "fmt" "os" - "strings" - colorable "github.com/mattn/go-colorable" - "github.com/square/certigo/lib" - "github.com/square/certigo/starttls" - "golang.org/x/crypto/ssh/terminal" - kingpin "gopkg.in/alecthomas/kingpin.v2" + "github.com/square/certigo/cli" + "github.com/square/certigo/cli/terminal" ) -var ( - app = kingpin.New("certigo", "A command-line utility to examine and validate certificates to help with debugging SSL/TLS issues.") - verbose = app.Flag("verbose", "Print verbose").Short('v').Bool() - - dump = app.Command("dump", "Display information about a certificate from a file or stdin.") - dumpFiles = dump.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFiles() - dumpType = dump.Flag("format", "Format of given input (PEM, DER, JCEKS, PKCS12; heuristic if missing).").Short('f').String() - dumpPassword = dump.Flag("password", "Password for PKCS12/JCEKS key stores (reads from TTY if missing).").Short('p').String() - dumpPem = dump.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool() - dumpJSON = dump.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool() - - connect = app.Command("connect", "Connect to a server and print its certificate(s).") - connectTo = connect.Arg("server[:port]", "Hostname or IP to connect to, with optional port.").String() - connectName = connect.Flag("name", "Override the server name used for Server Name Indication (SNI).").Short('n').String() - connectCaPath = connect.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile() - connectCert = connect.Flag("cert", "Client certificate chain for connecting to server (PEM).").ExistingFile() - connectKey = connect.Flag("key", "Private key for client certificate, if not in same file (PEM).").ExistingFile() - connectStartTLS = connect.Flag("start-tls", fmt.Sprintf("Enable StartTLS protocol; one of: %v.", starttls.Protocols)).Short('t').PlaceHolder("PROTOCOL").Enum(starttls.Protocols...) - connectIdentity = connect.Flag("identity", "With --start-tls, sets the DB user or SMTP EHLO name").Default("certigo").String() - connectProxy = connect.Flag("proxy", "Optional URI for HTTP(s) CONNECT proxy to dial connections with").URL() - connectTimeout = connect.Flag("timeout", "Timeout for connecting to remote server (can be '5m', '1s', etc).").Default("5s").Duration() - connectPem = connect.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool() - connectJSON = connect.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool() - connectVerify = connect.Flag("verify", "Verify certificate chain.").Bool() - - verify = app.Command("verify", "Verify a certificate chain from file/stdin against a name.") - verifyFile = verify.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFile() - verifyType = verify.Flag("format", "Format of given input (PEM, DER, JCEKS, PKCS12; heuristic if missing).").Short('f').String() - verifyPassword = verify.Flag("password", "Password for PKCS12/JCEKS key stores (reads from TTY if missing).").Short('p').String() - verifyName = verify.Flag("name", "Server name to verify certificate against.").Short('n').Required().String() - verifyCaPath = verify.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile() - verifyJSON = verify.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool() -) - -const minWidth = 60 -const maxWidth = 80 - func main() { - app.Version("1.11.0") - - terminalWidth := determineTerminalWidth() - - // Alias starttls to start-tls - connect.Flag("starttls", "").Hidden().EnumVar(connectStartTLS, starttls.Protocols...) - // Use long help because many useful flags are under subcommands - app.UsageTemplate(kingpin.LongHelpTemplate) - - stdout := colorable.NewColorableStdout() - result := lib.SimpleResult{} - switch kingpin.MustParse(app.Parse(os.Args[1:])) { - case dump.FullCommand(): // Dump certificate - files := inputFiles(*dumpFiles) - defer func() { - for _, file := range files { - file.Close() - } - }() - - var err error - if *dumpPem { - err = lib.ReadAsPEMFromFiles(files, *dumpType, readPassword, func(block *pem.Block) { - block.Headers = nil - pem.Encode(os.Stdout, block) - }) - } else { - err = lib.ReadAsX509FromFiles(files, *dumpType, readPassword, func(cert *x509.Certificate, err error) { - if err != nil { - fmt.Fprintf(os.Stderr, "error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n")) - } else { - result.Certificates = append(result.Certificates, cert) - } - }) - - if *dumpJSON { - blob, _ := json.Marshal(result) - fmt.Println(string(blob)) - } else { - for i, cert := range result.Certificates { - fmt.Fprintf(stdout, "** CERTIFICATE %d **\n", i+1) - fmt.Fprintf(stdout, "%s\n\n", lib.EncodeX509ToText(cert, terminalWidth, *verbose)) - } - } - } - if err != nil { - fmt.Fprintf(os.Stderr, "error: %s\n", strings.TrimSuffix(err.Error(), "\n")) - os.Exit(1) - } else if len(result.Certificates) == 0 && !*dumpPem { - fmt.Fprintf(os.Stderr, "warning: no certificates found in input\n") - } - - case connect.FullCommand(): // Get certs by connecting to a server - if connectStartTLS == nil && connectIdentity != nil { - fmt.Fprintln(os.Stderr, "error: --identity can only be used with --start-tls") - os.Exit(1) - } - connState, cri, err := starttls.GetConnectionState( - *connectStartTLS, *connectName, *connectTo, *connectIdentity, - *connectCert, *connectKey, *connectProxy, *connectTimeout) - if err != nil { - fmt.Fprintf(os.Stderr, "%s\n", strings.TrimSuffix(err.Error(), "\n")) - os.Exit(1) - } - result.TLSConnectionState = connState - result.CertificateRequestInfo = cri - for _, cert := range connState.PeerCertificates { - if *connectPem { - pem.Encode(os.Stdout, lib.EncodeX509ToPEM(cert, nil)) - } else { - result.Certificates = append(result.Certificates, cert) - } - } - - var hostname string - if *connectName != "" { - hostname = *connectName - } else { - hostname = strings.Split(*connectTo, ":")[0] - } - verifyResult := lib.VerifyChain(connState.PeerCertificates, connState.OCSPResponse, hostname, *connectCaPath) - result.VerifyResult = &verifyResult - - if *connectJSON { - blob, _ := json.Marshal(result) - fmt.Println(string(blob)) - } else if !*connectPem { - fmt.Fprintf( - stdout, "%s\n\n", - lib.EncodeTLSInfoToText(result.TLSConnectionState, result.CertificateRequestInfo)) - - for i, cert := range result.Certificates { - fmt.Fprintf(stdout, "** CERTIFICATE %d **\n", i+1) - fmt.Fprintf(stdout, "%s\n\n", lib.EncodeX509ToText(cert, terminalWidth, *verbose)) - } - lib.PrintVerifyResult(stdout, *result.VerifyResult) - } - - if *connectVerify && len(result.VerifyResult.Error) > 0 { - os.Exit(1) - } - case verify.FullCommand(): - file := inputFile(*verifyFile) - defer file.Close() - - chain := []*x509.Certificate{} - lib.ReadAsX509FromFiles([]*os.File{file}, *verifyType, readPassword, func(cert *x509.Certificate, err error) { - if err != nil { - fmt.Fprintf(os.Stderr, "error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n")) - } else { - chain = append(chain, cert) - } - }) - - verifyResult := lib.VerifyChain(chain, nil, *verifyName, *verifyCaPath) - if *verifyJSON { - blob, _ := json.Marshal(verifyResult) - fmt.Println(string(blob)) - } else { - lib.PrintVerifyResult(stdout, verifyResult) - } - if verifyResult.Error != "" { - os.Exit(1) - } - } -} - -func inputFile(fileName string) *os.File { - if fileName == "" { - return os.Stdin - } - - rawFile, err := os.Open(fileName) - if err != nil { - fmt.Fprintf(os.Stderr, "unable to open file: %s\n", err) - os.Exit(1) - } - return rawFile -} - -func inputFiles(fileNames []string) []*os.File { - files := []*os.File{} - if fileNames != nil { - for _, filename := range fileNames { - rawFile, err := os.Open(filename) - if err != nil { - fmt.Fprintf(os.Stderr, "unable to open file: %s\n", err) - os.Exit(1) - } - files = append(files, rawFile) - } - } else { - files = append(files, os.Stdin) - } - return files -} - -func determineTerminalWidth() (width int) { - fd := int(os.Stdout.Fd()) - if terminal.IsTerminal(fd) { - var err error - width, _, err = terminal.GetSize(fd) - if err != nil { - width = minWidth - } - } else { - width = minWidth - } - - if width > maxWidth { - width = maxWidth - } else if width < minWidth { - width = minWidth - } - return -} - -func readPassword(alias string) string { - if *dumpPassword != "" { - return *dumpPassword - } - if *verifyPassword != "" { - return *verifyPassword - } - - var tty *os.File - tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) - if err != nil { - tty = os.Stdin - } else { - defer tty.Close() - } - - tty.WriteString("Enter password") - if alias != "" { - tty.WriteString(fmt.Sprintf(" for entry [%s]", alias)) - } - tty.WriteString(": ") - - password, err := terminal.ReadPassword(int(tty.Fd())) - tty.WriteString("\n") - if err != nil { - fmt.Fprintf(os.Stderr, "error reading password: %s\n", err) - os.Exit(1) - } - - return strings.TrimSuffix(string(password), "\n") + os.Exit(cli.Run(os.Args[1:], terminal.OpenTTY())) } diff --git a/starttls/starttls.go b/starttls/starttls.go index cdcfe1f..fa49b51 100644 --- a/starttls/starttls.go +++ b/starttls/starttls.go @@ -33,7 +33,7 @@ import ( ) // Protocols are the names of supported protocols -var Protocols []string = []string{"mysql", "postgres", "psql", "smtp", "ldap", "ftp", "imap"} +var Protocols = []string{"mysql", "postgres", "psql", "smtp", "ldap", "ftp", "imap"} type connectResult struct { state *tls.ConnectionState