From de591101df4d7aee99ba1fa9cbfec55d8b4f14fe Mon Sep 17 00:00:00 2001 From: Steven Mertens Date: Thu, 12 Mar 2026 21:44:07 +0100 Subject: [PATCH] feat: auto-retry on TLS verification errors and detect incomplete chains - On TLS verification failure (expired, hostname mismatch, unknown authority), automatically retry with InsecureSkipVerify to capture certificate details and display them with an insecure status - Distinguish incomplete chains from unknown authority errors by checking if the topmost certificate is self-signed - Remove the --insecure/-k flag from the client subcommand since verification errors are now handled automatically - Update README examples to match current binary output --- README.md | 48 ++++++++++++++++++--------------- cmd/client.go | 3 --- internal/config/config.go | 4 --- internal/config/config_test.go | 1 - internal/tlsquery/pem.go | 2 +- internal/tlsquery/query.go | 45 +++++++++++++++++++++++++------ internal/tlsquery/query_test.go | 12 ++++----- internal/tlsquery/types.go | 1 - 8 files changed, 71 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 923786c..94105e9 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ ``` $ tlsctl client github.com -github.com (secure, expires in 52 days) ✓ +github.com (secure, expires in 84 days) ✓ Subject: CN=github.com Issuer: CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB - Validity: 2026-01-06 → 2026-04-05 + Validity: 2026-03-06 → 2026-06-03 SANs: github.com, www.github.com Chain: github.com → Sectigo Public Server Authentication CA DV E36 → Sectigo Public Server Authentication Root E46 (3 certificates) @@ -129,7 +129,7 @@ tlsctl pem cert.pem ``` $ tlsctl client badssl.com -*.badssl.com (secure, expires in 66 days) ✓ +*.badssl.com (secure, expires in 40 days) ✓ Subject: CN=*.badssl.com Issuer: CN=R13,O=Let's Encrypt,C=US Validity: 2026-01-20 → 2026-04-20 @@ -142,7 +142,7 @@ $ tlsctl client badssl.com ``` $ tlsctl client expired.badssl.com -*.badssl.com (insecure, certificate expired, expires in -3958 days) ✗ +*.badssl.com (insecure, certificate expired, expired 3985 days ago) ✗ Subject: CN=*.badssl.com,OU=Domain Control Validated+OU=PositiveSSL Wildcard Issuer: CN=COMODO RSA Domain Validation Secure Server CA,O=COMODO CA Limited,L=Salford,ST=Greater Manchester,C=GB Validity: 2015-04-09 → 2015-04-12 @@ -155,7 +155,7 @@ $ tlsctl client expired.badssl.com ``` $ tlsctl client wrong.host.badssl.com -*.badssl.com (insecure, hostname mismatch, expires in 66 days) ✗ +*.badssl.com (insecure, hostname mismatch, expires in 40 days) ✗ Subject: CN=*.badssl.com Issuer: CN=R13,O=Let's Encrypt,C=US Validity: 2026-01-20 → 2026-04-20 @@ -168,10 +168,10 @@ $ tlsctl client wrong.host.badssl.com ``` $ tlsctl client self-signed.badssl.com -*.badssl.com (insecure, unknown authority, expires in 727 days) ✗ +*.badssl.com (insecure, unknown authority, expires in 729 days) ✗ Subject: CN=*.badssl.com,O=BadSSL,L=San Francisco,ST=California,C=US Issuer: CN=*.badssl.com,O=BadSSL,L=San Francisco,ST=California,C=US - Validity: 2026-02-10 → 2028-02-10 + Validity: 2026-03-10 → 2028-03-09 SANs: *.badssl.com, badssl.com ``` @@ -179,10 +179,10 @@ $ tlsctl client self-signed.badssl.com ``` $ tlsctl client untrusted-root.badssl.com -*.badssl.com (insecure, unknown authority, expires in 727 days) ✗ +*.badssl.com (insecure, unknown authority, expires in 729 days) ✗ Subject: CN=*.badssl.com,O=BadSSL,L=San Francisco,ST=California,C=US Issuer: CN=BadSSL Untrusted Root Certificate Authority,O=BadSSL,L=San Francisco,ST=California,C=US - Validity: 2026-02-10 → 2028-02-10 + Validity: 2026-03-10 → 2028-03-09 SANs: *.badssl.com, badssl.com Chain: *.badssl.com → BadSSL Untrusted Root Certificate Authority (2 certificates) @@ -192,7 +192,7 @@ $ tlsctl client untrusted-root.badssl.com ``` $ tlsctl client incomplete-chain.badssl.com -*.badssl.com (insecure, unknown authority, expires in 66 days) ✗ +*.badssl.com (insecure, incomplete chain, expires in 39 days) ✗ Subject: CN=*.badssl.com Issuer: CN=R13,O=Let's Encrypt,C=US Validity: 2026-01-20 → 2026-04-20 @@ -203,24 +203,24 @@ $ tlsctl client incomplete-chain.badssl.com ``` $ tlsctl client --revocation crl revoked.badssl.com -revoked.badssl.com (secure, expires in 52 days) ✓ +revoked.badssl.com (secure, expires in 89 days) ✓ Subject: CN=revoked.badssl.com - Issuer: CN=E7,O=Let's Encrypt,C=US - Validity: 2026-01-06 → 2026-04-06 + Issuer: CN=E8,O=Let's Encrypt,C=US + Validity: 2026-03-10 → 2026-06-08 SANs: revoked.badssl.com Revocation: REVOKED (CRL) - Chain: revoked.badssl.com → E7 (2 certificates) + Chain: revoked.badssl.com → E8 (2 certificates) ``` ### Revocation checking with OCSP ``` $ tlsctl client --revocation ocsp google.com -*.google.com (secure, expires in 59 days) ✓ +*.google.com (secure, expires in 46 days) ✓ Subject: CN=*.google.com Issuer: CN=WR2,O=Google Trust Services,C=US - Validity: 2026-01-19 → 2026-04-13 + Validity: 2026-02-02 → 2026-04-27 SANs: *.google.com, *.appengine.google.com, *.bdn.dev, *.origin-test.bdn.dev, *.cloud.google.com (+132 more) Revocation: not revoked (OCSP) @@ -233,18 +233,24 @@ Use `--tls-versions` to probe supported TLS versions and enumerate server-side c ``` $ tlsctl client --tls-versions badssl.com -*.badssl.com (secure, expires in 66 days) ✓ +*.badssl.com (secure, expires in 40 days) ✓ Subject: CN=*.badssl.com Issuer: CN=R13,O=Let's Encrypt,C=US Validity: 2026-01-20 → 2026-04-20 SANs: *.badssl.com, badssl.com - TLS: TLS 1.2, TLS 1.3 + TLS: TLS 1.0, TLS 1.1, TLS 1.2 + Ciphers (TLS 1.0): + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA + ... + Ciphers (TLS 1.1): + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA + ... Ciphers (TLS 1.2): TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 - Ciphers (TLS 1.3): - TLS_AES_256_GCM_SHA384 + ... Chain: *.badssl.com → R13 (2 certificates) ``` diff --git a/cmd/client.go b/cmd/client.go index 55f1ca5..3de1f41 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -51,7 +51,6 @@ func newClientCmd(rt *Runtime) *cobra.Command { var tlsVersions bool var serverName string var startTLS string - var insecure bool var rf revocationFlags cmd := &cobra.Command{ @@ -90,7 +89,6 @@ func newClientCmd(rt *Runtime) *cobra.Command { TLSVersions: tlsVersions, ServerName: serverName, StartTLS: startTLS, - Insecure: insecure, } now := rt.NowFunc() @@ -141,7 +139,6 @@ func newClientCmd(rt *Runtime) *cobra.Command { cmd.Flags().BoolVar(&tlsVersions, "tls-versions", false, "Probe and display supported TLS versions") cmd.Flags().StringVar(&serverName, "servername", "", "Override the SNI server name sent in the TLS handshake") cmd.Flags().StringVar(&startTLS, "starttls", "", "Use STARTTLS for the given protocol: "+tlsquery.StartTLSProtocolList()) - cmd.Flags().BoolVarP(&insecure, "insecure", "k", false, "Skip TLS certificate verification") addRevocationFlags(cmd, &rf) addCertFlags(cmd) diff --git a/internal/config/config.go b/internal/config/config.go index c5b3cd3..9656d79 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,7 +52,6 @@ type ClientSettings struct { TLSVersions *bool `json:"tls-versions,omitempty"` ServerName *string `json:"servername,omitempty"` StartTLS *string `json:"starttls,omitempty"` - Insecure *bool `json:"insecure,omitempty"` Revocation *string `json:"revocation,omitempty"` RevocationTimeout *Duration `json:"revocation-timeout,omitempty"` RevocationSoftFail *bool `json:"revocation-soft-fail,omitempty"` @@ -213,9 +212,6 @@ func addClientFlags(vals map[string]string, c *ClientSettings) { if c.StartTLS != nil { vals["starttls"] = *c.StartTLS } - if c.Insecure != nil { - vals["insecure"] = boolStr(*c.Insecure) - } if c.Revocation != nil { vals["revocation"] = *c.Revocation } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8a47312..d848e5c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -128,7 +128,6 @@ func TestLoad_ValidConfig(t *testing.T) { "revocation": "ocsp", "revocation-timeout": "10s", "revocation-soft-fail": false, - "insecure": false, "servername": "example.com", "starttls": "smtp", "cacert": "/etc/ssl/ca.pem", diff --git a/internal/tlsquery/pem.go b/internal/tlsquery/pem.go index 432817f..c2f1dc2 100644 --- a/internal/tlsquery/pem.go +++ b/internal/tlsquery/pem.go @@ -89,7 +89,7 @@ func verifyPEMChain(chain *ChainInfo, certs []*x509.Certificate, opts PEMOptions if _, err := leaf.Verify(verifyOpts); err != nil { chain.Verified = false - chain.VerificationError = abbreviateVerifyError(err) + chain.VerificationError = abbreviateVerifyErrorWithChain(err, certs) } else { chain.Verified = true } diff --git a/internal/tlsquery/query.go b/internal/tlsquery/query.go index 19dc3b0..ace1fea 100644 --- a/internal/tlsquery/query.go +++ b/internal/tlsquery/query.go @@ -1,6 +1,7 @@ package tlsquery import ( + "bytes" "crypto/tls" "crypto/x509" "errors" @@ -30,27 +31,29 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { return nil, fmt.Errorf("invalid proxy configuration: %w", err) } - if opts.Insecure { + certs, err := dialAndHandshake(endpoint, proxyURL, config, startTLS) + if err != nil { + if !isVerificationError(err) { + return nil, fmt.Errorf("TLS handshake failed: %w", err) + } + verifyErr := err + insecureConfig := config.Clone() insecureConfig.InsecureSkipVerify = true - certs, err := dialAndHandshake(endpoint, proxyURL, insecureConfig, startTLS) + certs, err = dialAndHandshake(endpoint, proxyURL, insecureConfig, startTLS) if err != nil { return nil, fmt.Errorf("TLS handshake failed: %w", err) } + chain := buildChain(certs) chain.Verified = false - chain.VerificationError = "verification skipped (--insecure)" + chain.VerificationError = abbreviateVerifyErrorWithChain(verifyErr, certs) if probeVersions { chain.TLSVersions = probeTLSVersions(endpoint, proxyURL, config, true, startTLS) } return chain, nil } - certs, err := dialAndHandshake(endpoint, proxyURL, config, startTLS) - if err != nil { - return nil, fmt.Errorf("TLS handshake failed: %w", err) - } - chain := buildChain(certs) chain.Verified = true if probeVersions { @@ -94,6 +97,32 @@ func buildChain(certs []*x509.Certificate) *ChainInfo { return chain } +func isVerificationError(err error) bool { + var hostErr x509.HostnameError + var unknownAuth x509.UnknownAuthorityError + var certInvalid x509.CertificateInvalidError + var sysRoots x509.SystemRootsError + return errors.As(err, &hostErr) || + errors.As(err, &unknownAuth) || + errors.As(err, &certInvalid) || + errors.As(err, &sysRoots) +} + +func abbreviateVerifyErrorWithChain(err error, certs []*x509.Certificate) string { + var unknownAuth x509.UnknownAuthorityError + if errors.As(err, &unknownAuth) && len(certs) > 0 { + top := certs[len(certs)-1] + if !isSelfSigned(top) { + return "incomplete chain" + } + } + return abbreviateVerifyError(err) +} + +func isSelfSigned(cert *x509.Certificate) bool { + return bytes.Equal(cert.RawIssuer, cert.RawSubject) +} + func abbreviateVerifyError(err error) string { var hostErr x509.HostnameError if errors.As(err, &hostErr) { diff --git a/internal/tlsquery/query_test.go b/internal/tlsquery/query_test.go index 4404c7b..51930e9 100644 --- a/internal/tlsquery/query_test.go +++ b/internal/tlsquery/query_test.go @@ -24,7 +24,7 @@ func TestQuery_ValidEndpoint(t *testing.T) { server, addr := startTestTLSServer(t, false) defer server.Close() - chain, err := Query(addr, QueryOptions{Insecure: true}) + chain, err := Query(addr, QueryOptions{}) if err != nil { t.Fatalf("Query failed: %v", err) } @@ -44,10 +44,10 @@ func TestQuery_ValidEndpoint(t *testing.T) { t.Errorf("expected 2 SANs, got %d", len(leaf.SubjectAltNames)) } if chain.Verified { - t.Error("expected Verified to be false with Insecure option") + t.Error("expected Verified to be false for self-signed test certificate") } if chain.VerificationError == "" { - t.Error("expected VerificationError to be set with Insecure option") + t.Error("expected VerificationError to be set for self-signed test certificate") } } @@ -309,7 +309,7 @@ func TestQuery_ViaProxy(t *testing.T) { proxyAddr, cleanup := startTestHTTPProxy(t, addr, false) defer cleanup() - chain, err := Query(addr, QueryOptions{Proxy: "http://" + proxyAddr, Insecure: true}) + chain, err := Query(addr, QueryOptions{Proxy: "http://" + proxyAddr}) if err != nil { t.Fatalf("Query via proxy failed: %v", err) } @@ -329,7 +329,7 @@ func TestQuery_ViaProxyWithAuth(t *testing.T) { proxyAddr, cleanup := startTestHTTPProxy(t, addr, true) defer cleanup() - chain, err := Query(addr, QueryOptions{Proxy: "http://user:pass@" + proxyAddr, Insecure: true}) + chain, err := Query(addr, QueryOptions{Proxy: "http://user:pass@" + proxyAddr}) if err != nil { t.Fatalf("Query via proxy with auth failed: %v", err) } @@ -346,7 +346,7 @@ func TestQuery_ViaProxyAuthRequired(t *testing.T) { proxyAddr, cleanup := startTestHTTPProxy(t, addr, true) defer cleanup() - _, err := Query(addr, QueryOptions{Proxy: "http://" + proxyAddr, Insecure: true}) + _, err := Query(addr, QueryOptions{Proxy: "http://" + proxyAddr}) if err == nil { t.Error("expected error when proxy requires auth but none provided") } diff --git a/internal/tlsquery/types.go b/internal/tlsquery/types.go index 9ec1227..6cad5b2 100644 --- a/internal/tlsquery/types.go +++ b/internal/tlsquery/types.go @@ -68,5 +68,4 @@ type QueryOptions struct { TLSVersions bool // Probe and display supported TLS versions. ServerName string // SNI override for TLS handshake (useful when connecting by IP). StartTLS string // STARTTLS protocol: smtp, imap, pop3, ldap. - Insecure bool // Skip TLS certificate verification. }