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