From 7855a099558c7cf2dfe1e25e03b0e3d67aea841e Mon Sep 17 00:00:00 2001 From: Steven Mertens Date: Mon, 6 Apr 2026 22:16:00 +0200 Subject: [PATCH] feat: surface negotiated tls details and ALPN --- README.md | 53 +++++++++++++++++++++++---- cmd/client.go | 7 ++++ cmd/client_targets_test.go | 45 +++++++++++++++++++++++ cmd/render_test.go | 26 +++++++++++++- internal/config/config.go | 15 ++++++++ internal/config/config_test.go | 22 ++++++++++++ internal/output/csv.go | 38 +++++++++++++++++++- internal/output/human.go | 21 +++++++++++ internal/output/renderer_test.go | 61 ++++++++++++++++++++++++++++++++ internal/output/verbose.go | 8 +++++ internal/tlsquery/chain.go | 1 + internal/tlsquery/dial.go | 16 ++++++--- internal/tlsquery/query.go | 28 +++++++++++++-- internal/tlsquery/query_test.go | 34 ++++++++++++++++++ internal/tlsquery/types.go | 9 +++++ 15 files changed, 370 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bfa7fbb..053b137 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ tlsctl client --file hosts.txt # Probe supported TLS versions and cipher suites tlsctl client --tls-versions example.com +# Show what the default handshake negotiated +tlsctl client example.com + +# Negotiate ALPN explicitly +tlsctl client --alpn h2,http/1.1 example.com + # Use a custom port tlsctl client example.com:8443 @@ -142,6 +148,7 @@ $ tlsctl client badssl.com Subject: CN=*.badssl.com Issuer: CN=R13,O=Let's Encrypt,C=US Validity: 2026-01-20 → 2026-04-20 + Handshake: TLS 1.2 / TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 SANs: *.badssl.com, badssl.com Chain: *.badssl.com → R13 (2 certificates) @@ -273,6 +280,29 @@ In human output, insecure cipher suites are highlighted in red and tagged with ` In non-human outputs (`json`, `yaml`, `csv-full`, and `text`), cipher suites are split into `secure_cipher_suites` and `insecure_cipher_suites`. +### Showing negotiated handshake details + +The human-readable client output always shows the negotiated TLS version, cipher suite, and, +when present, ALPN for the default connection. If you want ALPN to be negotiated, advertise +protocols with `--alpn`: + +``` +$ tlsctl client --alpn h2,http/1.1 github.com +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-03-06 → 2026-06-03 + Handshake: TLS 1.3 / TLS_AES_128_GCM_SHA256 / h2 + SANs: github.com, www.github.com + + Chain: github.com → Sectigo Public Server Authentication CA DV E36 → Sectigo Public Server Authentication Root E46 (3 certificates) +``` + +Structured client outputs include the same data under `negotiated_tls` by default. + +`--alpn` accepts a comma-separated list such as `h2,http/1.1` and forwards it to the TLS client +handshake as the advertised ALPN protocol list. + ### Verbose text output Use `-o text` for the full certificate details: @@ -327,6 +357,11 @@ $ tlsctl client -o json badssl.com ```json { + "negotiated_tls": { + "tls_version": "TLS 1.3", + "cipher_suite": "TLS_AES_128_GCM_SHA256", + "alpn": "h2" + }, "certificates": [ { "type": "leaf", @@ -375,6 +410,11 @@ $ tlsctl client -o json --format-version 2 github.com missing.example.com "status": "success", "tls_status": "secure", "result": { + "negotiated_tls": { + "tls_version": "TLS 1.3", + "cipher_suite": "TLS_AES_128_GCM_SHA256", + "alpn": "h2" + }, "certificates": [ { "type": "leaf", @@ -447,16 +487,16 @@ $ tlsctl client -o csv badssl.com ``` ```csv -target,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names -badssl.com:443,*.badssl.com,"CN=R13,O=Let's Encrypt,C=US",2026-01-20T20:02:51Z,2026-04-20T20:02:50Z,90,b4:5a:53:24:32:d9:8f:62:b6:ea:f1:47:32:06:10:f1:...,"*.badssl.com; badssl.com" +target,tls_version,cipher_suite,alpn,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names +badssl.com:443,TLS 1.2,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,,*.badssl.com,"CN=R13,O=Let's Encrypt,C=US",2026-01-20T20:02:51Z,2026-04-20T20:02:50Z,90,b4:5a:53:24:32:d9:8f:62:b6:ea:f1:47:32:06:10:f1:...,"*.badssl.com; badssl.com" ``` Use `--format-version 2` with `csv` or `csv-full` if you want failed targets included inline with `status` and `error` columns: ```csv -target,status,tls_status,error,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names -github.com:443,success,secure,,github.com,"CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB",2026-03-06T00:00:00Z,2026-06-03T23:59:59Z,89,ab:cd:ef:...,"github.com; www.github.com" -missing.example.com:443,failure,,connection failed: dial tcp: lookup missing.example.com: no such host,,,,,,, +target,status,tls_status,error,tls_version,cipher_suite,alpn,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names +github.com:443,success,secure,,TLS 1.3,TLS_AES_128_GCM_SHA256,h2,github.com,"CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB",2026-03-06T00:00:00Z,2026-06-03T23:59:59Z,89,ab:cd:ef:...,"github.com; www.github.com" +missing.example.com:443,failure,,connection failed: dial tcp: lookup missing.example.com: no such host,,,,,,,,,, ``` Use `-o csv-full` if you want the row-per-certificate export with the wider field set. @@ -707,6 +747,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul "format-version": 2, "proxy": "http://proxy:8080", "tls-versions": true, + "alpn": "h2,http/1.1", "connect-timeout": "3s", "handshake-timeout": "6s", "revocation-soft-fail": false @@ -718,7 +759,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul } ``` -The `global` section applies to all subcommands and supports: `no-color`, `quiet`, `expiry-warning`, `output`, `cacert`, `connect-timeout`, `handshake-timeout`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. The `client` section also supports `format-version`, `proxy`, `file`, `tls-versions`, `servername`, and `starttls` for client-specific behavior. Each subcommand section (`client`, `pem`) can override any global value with subcommand-specific settings. Only set the values you want to override — omitted keys inherit from `global` or use built-in defaults. +The `global` section applies to all subcommands and supports: `no-color`, `quiet`, `expiry-warning`, `output`, `cacert`, `connect-timeout`, `handshake-timeout`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. The `client` section also supports `format-version`, `proxy`, `file`, `tls-versions`, `alpn`, `servername`, and `starttls` for client-specific behavior. Each subcommand section (`client`, `pem`) can override any global value with subcommand-specific settings. Only set the values you want to override — omitted keys inherit from `global` or use built-in defaults. Invalid JSON, unknown keys, and invalid values (e.g., out-of-range `expiry-warning`) produce clear error messages. diff --git a/cmd/client.go b/cmd/client.go index 6138a58..81eb3b2 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -70,6 +70,7 @@ func newClientCmd(rt *Runtime) *cobra.Command { var proxyURL string var inputFile string var tlsVersions bool + var alpnProtocols string var serverName string var startTLS string var rf revocationFlags @@ -105,6 +106,10 @@ func newClientCmd(rt *Runtime) *cobra.Command { if startTLS != "" && !tlsquery.ValidStartTLSProtocol(startTLS) { return fmt.Errorf("invalid --starttls protocol %q: must be one of %s", startTLS, tlsquery.StartTLSProtocolList()) } + parsedALPNProtocols, err := tlsquery.ParseALPNProtocols(alpnProtocols) + if err != nil { + return fmt.Errorf("invalid --alpn value: %w", err) + } targets, err := collectTargets(args, inputFile, startTLS) if err != nil { @@ -115,6 +120,7 @@ func newClientCmd(rt *Runtime) *cobra.Command { CACertFile: caCertFile, Proxy: proxyURL, TLSVersions: tlsVersions, + ALPNProtocols: parsedALPNProtocols, ServerName: serverName, StartTLS: startTLS, ConnectTimeout: cf.connectTimeout, @@ -172,6 +178,7 @@ func newClientCmd(rt *Runtime) *cobra.Command { cmd.Flags().StringVarP(&proxyURL, "proxy", "x", "", "Proxy URL (e.g. http://proxy:8080). Falls back to HTTPS_PROXY/HTTP_PROXY env vars if not set") cmd.Flags().StringVar(&inputFile, "file", "", "Read endpoints from file (one per line, '-' for stdin)") cmd.Flags().BoolVar(&tlsVersions, "tls-versions", false, "Probe and display supported TLS versions") + cmd.Flags().StringVar(&alpnProtocols, "alpn", "", "Comma-separated ALPN protocols to advertise in the TLS handshake (for example: h2,http/1.1)") 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()) addRevocationFlags(cmd, &rf) diff --git a/cmd/client_targets_test.go b/cmd/client_targets_test.go index e148c0e..67e884d 100644 --- a/cmd/client_targets_test.go +++ b/cmd/client_targets_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/catay/tlsctl/internal/output" + "github.com/catay/tlsctl/internal/tlsquery" ) func TestCollectTargets(t *testing.T) { @@ -162,3 +163,47 @@ func TestValidateConnectionTimeouts(t *testing.T) { }) } } + +func TestParseALPNProtocols(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {name: "empty", input: "", want: nil}, + {name: "single", input: "h2", want: []string{"h2"}}, + {name: "multiple", input: "h2,http/1.1", want: []string{"h2", "http/1.1"}}, + {name: "trim spaces", input: " h2, http/1.1 ", want: []string{"h2", "http/1.1"}}, + {name: "empty entry", input: "h2,,http/1.1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tlsquery.ParseALPNProtocols(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewClientCmdALPNFlag(t *testing.T) { + cmd := newClientCmd(defaultRuntime) + flag := cmd.Flags().Lookup("alpn") + if flag == nil { + t.Fatal("expected --alpn flag to be registered") + } + if flag.DefValue != "" { + t.Fatalf("expected --alpn default to be empty, got %q", flag.DefValue) + } +} diff --git a/cmd/render_test.go b/cmd/render_test.go index 1f4615c..c068537 100644 --- a/cmd/render_test.go +++ b/cmd/render_test.go @@ -18,6 +18,11 @@ func testChains() []*tlsquery.ChainInfo { InputName: "a.example.com:443", InputLabel: "target", Verified: true, + NegotiatedTLS: &tlsquery.HandshakeInfo{ + TLSVersion: "TLS 1.3", + CipherSuite: "TLS_AES_128_GCM_SHA256", + ALPN: "h2", + }, Certificates: []tlsquery.CertInfo{ { Type: "leaf", @@ -34,6 +39,10 @@ func testChains() []*tlsquery.ChainInfo { InputName: "b.example.com:443", InputLabel: "target", Verified: true, + NegotiatedTLS: &tlsquery.HandshakeInfo{ + TLSVersion: "TLS 1.2", + CipherSuite: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + }, Certificates: []tlsquery.CertInfo{ { Type: "leaf", @@ -260,12 +269,18 @@ func TestRenderTargetResults_MultiCSVLegacy(t *testing.T) { if len(rows) != 2 { t.Fatalf("expected header plus one successful row, got %d rows", len(rows)) } - if rows[0][0] != "target" || rows[0][1] != "common_name" { + if rows[0][0] != "target" || rows[0][1] != "tls_version" { t.Fatalf("unexpected legacy CSV headers: %v", rows[0][:2]) } + if rows[0][1] != "tls_version" || rows[0][2] != "cipher_suite" || rows[0][3] != "alpn" || rows[0][4] != "common_name" { + t.Fatalf("unexpected legacy CSV negotiated tls headers: %v", rows[0][:5]) + } if rows[1][0] != "a.example.com:443" { t.Fatalf("unexpected successful CSV row: %v", rows[1]) } + if rows[1][1] != "TLS 1.3" || rows[1][2] != "TLS_AES_128_GCM_SHA256" || rows[1][3] != "h2" { + t.Fatalf("unexpected negotiated tls values in successful CSV row: %v", rows[1][:4]) + } } func TestRenderTargetResults_SingleJSONBatchV2(t *testing.T) { @@ -320,10 +335,19 @@ func TestRenderTargetResults_MultiCSVBatchV2(t *testing.T) { if rows[0][0] != "target" || rows[0][1] != "status" || rows[0][2] != "tls_status" || rows[0][3] != "error" { t.Fatalf("unexpected CSV v2 headers: %v", rows[0][:4]) } + if rows[0][4] != "tls_version" || rows[0][5] != "cipher_suite" || rows[0][6] != "alpn" { + t.Fatalf("unexpected CSV v2 negotiated tls headers: %v", rows[0][:7]) + } if rows[1][0] != "a.example.com:443" || rows[1][1] != "success" || rows[1][2] != "secure" || rows[1][3] != "" { t.Fatalf("unexpected success row: %v", rows[1][:4]) } + if rows[1][4] != "TLS 1.3" || rows[1][5] != "TLS_AES_128_GCM_SHA256" || rows[1][6] != "h2" { + t.Fatalf("unexpected negotiated tls values in success row: %v", rows[1][:7]) + } if rows[2][0] != "missing.example.com:443" || rows[2][1] != "failure" || rows[2][2] != "" || rows[2][3] != "connection failed" { t.Fatalf("unexpected failed row: %v", rows[2][:4]) } + if rows[2][4] != "" || rows[2][5] != "" || rows[2][6] != "" { + t.Fatalf("expected empty negotiated tls values in failed row: %v", rows[2][:7]) + } } diff --git a/internal/config/config.go b/internal/config/config.go index 33a17c3..5bd8bfb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,7 @@ type ClientSettings struct { Proxy *string `json:"proxy,omitempty"` File *string `json:"file,omitempty"` TLSVersions *bool `json:"tls-versions,omitempty"` + ALPN *string `json:"alpn,omitempty"` ServerName *string `json:"servername,omitempty"` StartTLS *string `json:"starttls,omitempty"` ConnectTimeout *Duration `json:"connect-timeout,omitempty"` @@ -155,6 +156,9 @@ func (s *Settings) validate() error { if err := validateStartTLS(s.Client.StartTLS); err != nil { return fmt.Errorf("client.starttls: %w", err) } + if err := validateALPN(s.Client.ALPN); err != nil { + return fmt.Errorf("client.alpn: %w", err) + } if err := validatePositiveDuration(s.Client.ConnectTimeout); err != nil { return fmt.Errorf("client.connect-timeout: %w", err) } @@ -199,6 +203,14 @@ func validateStartTLS(v *string) error { return fmt.Errorf("must be one of %s", tlsquery.StartTLSProtocolList()) } +func validateALPN(v *string) error { + if v == nil { + return nil + } + _, err := tlsquery.ParseALPNProtocols(*v) + return err +} + func validateFormatVersion(v *int) error { if v == nil { return nil @@ -295,6 +307,9 @@ func addClientFlags(vals map[string]string, c *ClientSettings) { if c.TLSVersions != nil { vals["tls-versions"] = boolStr(*c.TLSVersions) } + if c.ALPN != nil { + vals["alpn"] = *c.ALPN + } if c.ServerName != nil { vals["servername"] = *c.ServerName } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 50307ed..388fe19 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -115,6 +115,19 @@ func TestLoad_InvalidStartTLS(t *testing.T) { } } +func TestLoad_InvalidALPN(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if err := os.WriteFile(path, []byte(`{"client": {"alpn": "h2,,http/1.1"}}`), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + _, err := Load(path, false) + if err == nil { + t.Fatal("expected error for invalid alpn") + } +} + func TestLoad_InvalidFormatVersion(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "settings.json") @@ -165,6 +178,7 @@ func TestLoad_ValidConfig(t *testing.T) { "format-version": 2, "proxy": "http://proxy:8080", "tls-versions": true, + "alpn": "h2,http/1.1", "connect-timeout": "4s", "handshake-timeout": "9s", "revocation": "ocsp", @@ -216,6 +230,9 @@ func TestLoad_ValidConfig(t *testing.T) { if s.Client.TLSVersions == nil || !*s.Client.TLSVersions { t.Error("expected client.tls-versions = true") } + if s.Client.ALPN == nil || *s.Client.ALPN != "h2,http/1.1" { + t.Error("expected client.alpn = h2,http/1.1") + } if s.Client.ConnectTimeout == nil || s.Client.ConnectTimeout.Duration != 4*time.Second { t.Error("expected client.connect-timeout = 4s") } @@ -242,6 +259,7 @@ func TestFlagValues_Client(t *testing.T) { expiry := 21 output := "json" formatVersion := 2 + alpn := "h2,http/1.1" connectTimeout := Duration{Duration: 4 * time.Second} handshakeTimeout := Duration{Duration: 9 * time.Second} s := &Settings{ @@ -249,6 +267,7 @@ func TestFlagValues_Client(t *testing.T) { ExpiryWarning: &expiry, Output: &output, FormatVersion: &formatVersion, + ALPN: &alpn, ConnectTimeout: &connectTimeout, HandshakeTimeout: &handshakeTimeout, }, @@ -264,6 +283,9 @@ func TestFlagValues_Client(t *testing.T) { if vals["format-version"] != "2" { t.Errorf("expected format-version=2, got %s", vals["format-version"]) } + if vals["alpn"] != "h2,http/1.1" { + t.Errorf("expected alpn=h2,http/1.1, got %s", vals["alpn"]) + } if vals["connect-timeout"] != "4s" { t.Errorf("expected connect-timeout=4s, got %s", vals["connect-timeout"]) } diff --git a/internal/output/csv.go b/internal/output/csv.go index 4a7cd3c..b74f62d 100644 --- a/internal/output/csv.go +++ b/internal/output/csv.go @@ -41,6 +41,9 @@ func (CSVFullRenderer) RenderBatch(w io.Writer, results []TargetResult, opts Opt func renderCSVSummary(w io.Writer, chains []*tlsquery.ChainInfo, opts Options) error { headers := []string{ csvInputHeader(chains), + "tls_version", + "cipher_suite", + "alpn", "common_name", "issuer", "not_before", @@ -108,6 +111,9 @@ func csvSummaryRow(chain *tlsquery.ChainInfo, leaf *tlsquery.CertInfo, opts Opti return []string{ csvInputValue(chain), + csvNegotiatedTLSVersion(chain), + csvNegotiatedCipherSuite(chain), + csvNegotiatedALPN(chain), leaf.CommonName, leaf.Issuer, leaf.NotBefore, @@ -122,7 +128,7 @@ func csvSummaryBatchRow(result TargetResult, opts Options) ([]string, error) { record := csvSummaryBatchPrefix(result, opts) if result.Result == nil { - return append(record, "", "", "", "", "", "", ""), nil + return append(record, "", "", "", "", "", "", "", "", "", ""), nil } leaf, err := result.Result.Leaf() @@ -194,6 +200,9 @@ func renderCSVFullBatch(w io.Writer, results []TargetResult, opts Options) error func csvSummaryBatchHeaders(opts Options) []string { return append(csvBatchPrefixHeaders(opts), + "tls_version", + "cipher_suite", + "alpn", "common_name", "issuer", "not_before", @@ -235,6 +244,9 @@ func csvFullHeaders(inputHeader string) []string { "chain", "verified", "verification_error", + "tls_version", + "cipher_suite", + "alpn", "version", "serial_number", "signature_algorithm", @@ -292,6 +304,9 @@ func csvFullFieldsRow(certIndex int, chain *tlsquery.ChainInfo, cert *tlsquery.C strings.Join(chain.ChainNames(), " -> "), strconv.FormatBool(chain.Verified), chain.VerificationError, + csvNegotiatedTLSVersion(chain), + csvNegotiatedCipherSuite(chain), + csvNegotiatedALPN(chain), strconv.Itoa(cert.Version), cert.SerialNumber, cert.SignatureAlgorithm, @@ -364,6 +379,27 @@ func csvJoin(values []string) string { return strings.Join(values, "; ") } +func csvNegotiatedTLSVersion(chain *tlsquery.ChainInfo) string { + if chain == nil || chain.NegotiatedTLS == nil { + return "" + } + return chain.NegotiatedTLS.TLSVersion +} + +func csvNegotiatedCipherSuite(chain *tlsquery.ChainInfo) string { + if chain == nil || chain.NegotiatedTLS == nil { + return "" + } + return chain.NegotiatedTLS.CipherSuite +} + +func csvNegotiatedALPN(chain *tlsquery.ChainInfo) string { + if chain == nil || chain.NegotiatedTLS == nil { + return "" + } + return chain.NegotiatedTLS.ALPN +} + func csvTLSVersions(versions []tlsquery.TLSVersionInfo) string { names := make([]string, 0, len(versions)) for _, version := range versions { diff --git a/internal/output/human.go b/internal/output/human.go index f41bb11..06781ed 100644 --- a/internal/output/human.go +++ b/internal/output/human.go @@ -64,6 +64,9 @@ func (HumanRenderer) Render(w io.Writer, chain *tlsquery.ChainInfo, opts Options fmt.Fprintf(w, " Validity: %s → %s\n", notBefore.UTC().Format("2006-01-02"), notAfter.UTC().Format("2006-01-02")) + if chain.NegotiatedTLS != nil { + fmt.Fprintf(w, " Handshake: %s\n", formatHandshakeSummary(chain.NegotiatedTLS)) + } if len(leaf.SubjectAltNames) > 0 { sans := leaf.SubjectAltNames @@ -123,6 +126,24 @@ func (HumanRenderer) Render(w io.Writer, chain *tlsquery.ChainInfo, opts Options return nil } +func formatHandshakeSummary(info *tlsquery.HandshakeInfo) string { + if info == nil { + return "" + } + + parts := make([]string, 0, 3) + if info.TLSVersion != "" { + parts = append(parts, info.TLSVersion) + } + if info.CipherSuite != "" { + parts = append(parts, info.CipherSuite) + } + if info.ALPN != "" { + parts = append(parts, info.ALPN) + } + return strings.Join(parts, " / ") +} + func formatExpiryMsg(days int, expired bool) string { if expired { daysAgo := -days diff --git a/internal/output/renderer_test.go b/internal/output/renderer_test.go index c07274f..c9fd03e 100644 --- a/internal/output/renderer_test.go +++ b/internal/output/renderer_test.go @@ -25,6 +25,11 @@ func testChain() *tlsquery.ChainInfo { return &tlsquery.ChainInfo{ InputName: "test.example.com:443", InputLabel: "target", + NegotiatedTLS: &tlsquery.HandshakeInfo{ + TLSVersion: "TLS 1.3", + CipherSuite: "TLS_AES_128_GCM_SHA256", + ALPN: "h2", + }, Certificates: []tlsquery.CertInfo{ { Type: "leaf", @@ -138,6 +143,9 @@ func TestJSONRenderer(t *testing.T) { if len(result.TLSVersions) != 1 { t.Fatalf("expected 1 tls version entry, got %d", len(result.TLSVersions)) } + if result.NegotiatedTLS == nil || result.NegotiatedTLS.TLSVersion != "TLS 1.3" { + t.Fatalf("expected negotiated tls details in JSON output, got %+v", result.NegotiatedTLS) + } if len(result.TLSVersions[0].SecureCipherSuites) != 1 { t.Errorf("expected secure cipher suites in JSON output") } @@ -180,6 +188,9 @@ func TestYAMLRenderer(t *testing.T) { if len(result.TLSVersions) != 1 { t.Fatalf("expected 1 tls version entry, got %d", len(result.TLSVersions)) } + if result.NegotiatedTLS == nil || result.NegotiatedTLS.CipherSuite != "TLS_AES_128_GCM_SHA256" { + t.Fatalf("expected negotiated tls details in YAML output, got %+v", result.NegotiatedTLS) + } if len(result.TLSVersions[0].SecureCipherSuites) != 1 { t.Errorf("expected secure cipher suites in YAML output") } @@ -461,6 +472,15 @@ func TestCSVRenderer(t *testing.T) { if record["common_name"] != "test.example.com" { t.Errorf("expected common_name=test.example.com, got %q", record["common_name"]) } + if record["tls_version"] != "TLS 1.3" { + t.Errorf("expected tls_version=TLS 1.3, got %q", record["tls_version"]) + } + if record["cipher_suite"] != "TLS_AES_128_GCM_SHA256" { + t.Errorf("expected cipher_suite in CSV output, got %q", record["cipher_suite"]) + } + if record["alpn"] != "h2" { + t.Errorf("expected alpn=h2, got %q", record["alpn"]) + } if record["issuer"] != "CN=Test CA" { t.Errorf("expected issuer=CN=Test CA, got %q", record["issuer"]) } @@ -495,6 +515,9 @@ func TestCSVRenderer(t *testing.T) { if fullRecord["certificate_type"] != "leaf" { t.Errorf("expected certificate_type=leaf in csv-full, got %q", fullRecord["certificate_type"]) } + if fullRecord["tls_version"] != "TLS 1.3" || fullRecord["cipher_suite"] != "TLS_AES_128_GCM_SHA256" || fullRecord["alpn"] != "h2" { + t.Errorf("expected negotiated tls columns in csv-full, got tls_version=%q cipher_suite=%q alpn=%q", fullRecord["tls_version"], fullRecord["cipher_suite"], fullRecord["alpn"]) + } if !strings.Contains(fullRecord["secure_cipher_suites"], "TLS 1.2: "+secureCipher) { t.Errorf("expected secure cipher suites to include version-qualified entry, got %q", fullRecord["secure_cipher_suites"]) } @@ -545,6 +568,9 @@ func TestCSVRendererBatch(t *testing.T) { if record1["common_name"] != "test.example.com" { t.Errorf("unexpected common_name for success row: %q", record1["common_name"]) } + if record1["tls_version"] != "TLS 1.3" || record1["cipher_suite"] != "TLS_AES_128_GCM_SHA256" || record1["alpn"] != "h2" { + t.Errorf("expected negotiated tls fields for success row, got tls_version=%q cipher_suite=%q alpn=%q", record1["tls_version"], record1["cipher_suite"], record1["alpn"]) + } if record2["target"] != "missing.example.com:443" { t.Errorf("unexpected target for error row: %q", record2["target"]) } @@ -554,6 +580,9 @@ func TestCSVRendererBatch(t *testing.T) { if record2["common_name"] != "" { t.Errorf("expected empty common_name for failed row, got %q", record2["common_name"]) } + if record2["tls_version"] != "" || record2["cipher_suite"] != "" || record2["alpn"] != "" { + t.Errorf("expected empty negotiated tls fields for failed row, got tls_version=%q cipher_suite=%q alpn=%q", record2["tls_version"], record2["cipher_suite"], record2["alpn"]) + } } func TestCSVRendererBatchV2(t *testing.T) { @@ -598,6 +627,9 @@ func TestCSVRendererBatchV2(t *testing.T) { if record1["common_name"] != "test.example.com" { t.Errorf("unexpected common_name for success row: %q", record1["common_name"]) } + if record1["tls_version"] != "TLS 1.3" || record1["cipher_suite"] != "TLS_AES_128_GCM_SHA256" || record1["alpn"] != "h2" { + t.Errorf("expected negotiated tls fields for success row, got tls_version=%q cipher_suite=%q alpn=%q", record1["tls_version"], record1["cipher_suite"], record1["alpn"]) + } if record2["target"] != "missing.example.com:443" { t.Errorf("unexpected target for error row: %q", record2["target"]) } @@ -607,6 +639,9 @@ func TestCSVRendererBatchV2(t *testing.T) { if record2["common_name"] != "" { t.Errorf("expected empty common_name for failed row, got %q", record2["common_name"]) } + if record2["tls_version"] != "" || record2["cipher_suite"] != "" || record2["alpn"] != "" { + t.Errorf("expected empty negotiated tls fields for failed row, got tls_version=%q cipher_suite=%q alpn=%q", record2["tls_version"], record2["cipher_suite"], record2["alpn"]) + } } func TestCSVFullRendererBatch(t *testing.T) { @@ -743,6 +778,32 @@ func TestHumanRenderer(t *testing.T) { if !strings.Contains(output, "expires in") { t.Error("expected expiry info in output") } + if !strings.Contains(output, "Handshake: TLS 1.3 / TLS_AES_128_GCM_SHA256 / h2") { + t.Fatalf("expected handshake summary in human output, got %q", output) + } +} + +func TestVerboseTextRendererNegotiatedTLS(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := VerboseTextRenderer{} + + err := r.Render(&buf, chain, Options{Now: fixedNow}) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Negotiated TLS Version: TLS 1.3") { + t.Fatalf("expected negotiated tls version in verbose output, got %q", output) + } + if !strings.Contains(output, "Negotiated Cipher Suite: TLS_AES_128_GCM_SHA256") { + t.Fatalf("expected negotiated cipher suite in verbose output, got %q", output) + } + if !strings.Contains(output, "Negotiated ALPN: h2") { + t.Fatalf("expected negotiated ALPN in verbose output, got %q", output) + } } func TestHumanRendererExpired(t *testing.T) { diff --git a/internal/output/verbose.go b/internal/output/verbose.go index 009f619..5e9927f 100644 --- a/internal/output/verbose.go +++ b/internal/output/verbose.go @@ -21,6 +21,14 @@ func (VerboseTextRenderer) Render(w io.Writer, chain *tlsquery.ChainInfo, opts O } fmt.Fprintf(w, "%s Certificate verification failed: %s\n\n", color.YellowString("⚠"), reason) } + if chain.NegotiatedTLS != nil { + fmt.Fprintf(w, "Negotiated TLS Version: %s\n", chain.NegotiatedTLS.TLSVersion) + fmt.Fprintf(w, "Negotiated Cipher Suite: %s\n", chain.NegotiatedTLS.CipherSuite) + if chain.NegotiatedTLS.ALPN != "" { + fmt.Fprintf(w, "Negotiated ALPN: %s\n", chain.NegotiatedTLS.ALPN) + } + fmt.Fprintln(w) + } if len(chain.TLSVersions) > 0 { versions := make([]string, len(chain.TLSVersions)) for i, v := range chain.TLSVersions { diff --git a/internal/tlsquery/chain.go b/internal/tlsquery/chain.go index 1c1796e..458cf3e 100644 --- a/internal/tlsquery/chain.go +++ b/internal/tlsquery/chain.go @@ -14,6 +14,7 @@ func (c *ChainInfo) WithoutPEM() *ChainInfo { Certificates: make([]CertInfo, len(c.Certificates)), Verified: c.Verified, VerificationError: c.VerificationError, + NegotiatedTLS: c.NegotiatedTLS, TLSVersions: c.TLSVersions, InputName: c.InputName, InputLabel: c.InputLabel, diff --git a/internal/tlsquery/dial.go b/internal/tlsquery/dial.go index 2267c93..03bbfe9 100644 --- a/internal/tlsquery/dial.go +++ b/internal/tlsquery/dial.go @@ -9,20 +9,20 @@ import ( "time" ) -func dialAndHandshake(endpoint string, proxyURL *url.URL, config *tls.Config, startTLSProto string, connectTimeout, handshakeTimeout time.Duration) ([]*x509.Certificate, error) { +func dialAndHandshake(endpoint string, proxyURL *url.URL, config *tls.Config, startTLSProto string, connectTimeout, handshakeTimeout time.Duration) ([]*x509.Certificate, *HandshakeInfo, error) { conn, err := dialTLS(endpoint, proxyURL, config, connectTimeout, handshakeTimeout, startTLSProto) if err != nil { - return nil, err + return nil, nil, err } defer conn.Close() state := conn.ConnectionState() certs := state.PeerCertificates if len(certs) == 0 { - return nil, fmt.Errorf("no certificate returned by server") + return nil, nil, fmt.Errorf("no certificate returned by server") } - return certs, nil + return certs, negotiatedHandshakeInfo(state), nil } // tlsVersionName returns the human-readable name for a TLS version constant. @@ -41,6 +41,14 @@ func tlsVersionName(v uint16) string { } } +func negotiatedHandshakeInfo(state tls.ConnectionState) *HandshakeInfo { + return &HandshakeInfo{ + TLSVersion: tlsVersionName(state.Version), + CipherSuite: tls.CipherSuiteName(state.CipherSuite), + ALPN: state.NegotiatedProtocol, + } +} + // probeTLSVersions tests which TLS versions the server supports by attempting // a handshake with each version individually, and enumerates the supported // cipher suites in server-preferred order for each version. diff --git a/internal/tlsquery/query.go b/internal/tlsquery/query.go index 93401d7..e004bf2 100644 --- a/internal/tlsquery/query.go +++ b/internal/tlsquery/query.go @@ -12,6 +12,25 @@ import ( "time" ) +// ParseALPNProtocols normalizes a comma-separated ALPN protocol list. +func ParseALPNProtocols(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + + parts := strings.Split(value, ",") + protocols := make([]string, 0, len(parts)) + for _, part := range parts { + proto := strings.TrimSpace(part) + if proto == "" { + return nil, fmt.Errorf("empty ALPN protocol in %q", value) + } + protocols = append(protocols, proto) + } + + return protocols, nil +} + // Query connects to the given endpoint and retrieves certificate chain information. func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { config, err := buildConfig(opts) @@ -33,7 +52,7 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { return nil, fmt.Errorf("invalid proxy configuration: %w", err) } - certs, err := dialAndHandshake(endpoint, proxyURL, config, startTLS, connectTimeout, handshakeTimeout) + certs, negotiatedTLS, err := dialAndHandshake(endpoint, proxyURL, config, startTLS, connectTimeout, handshakeTimeout) if err != nil { if !isVerificationError(err) { return nil, fmt.Errorf("TLS handshake failed: %w", err) @@ -42,7 +61,7 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { insecureConfig := config.Clone() insecureConfig.InsecureSkipVerify = true - certs, err = dialAndHandshake(endpoint, proxyURL, insecureConfig, startTLS, connectTimeout, handshakeTimeout) + certs, negotiatedTLS, err = dialAndHandshake(endpoint, proxyURL, insecureConfig, startTLS, connectTimeout, handshakeTimeout) if err != nil { return nil, fmt.Errorf("TLS handshake failed: %w", err) } @@ -50,6 +69,7 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { chain := buildChain(certs) chain.Verified = false chain.VerificationError = abbreviateVerifyErrorWithChain(verifyErr, certs) + chain.NegotiatedTLS = negotiatedTLS chain.InputName = endpoint chain.InputLabel = "target" if probeVersions { @@ -60,6 +80,7 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { chain := buildChain(certs) chain.Verified = true + chain.NegotiatedTLS = negotiatedTLS chain.InputName = endpoint chain.InputLabel = "target" if probeVersions { @@ -88,6 +109,9 @@ func buildConfig(opts QueryOptions) (*tls.Config, error) { if opts.ServerName != "" { config.ServerName = opts.ServerName } + if len(opts.ALPNProtocols) > 0 { + config.NextProtos = append([]string(nil), opts.ALPNProtocols...) + } if opts.CACertFile != "" { caCert, err := os.ReadFile(opts.CACertFile) diff --git a/internal/tlsquery/query_test.go b/internal/tlsquery/query_test.go index fe11876..3b349a5 100644 --- a/internal/tlsquery/query_test.go +++ b/internal/tlsquery/query_test.go @@ -49,6 +49,9 @@ func TestQuery_ValidEndpoint(t *testing.T) { if chain.VerificationError == "" { t.Error("expected VerificationError to be set for self-signed test certificate") } + if chain.NegotiatedTLS == nil { + t.Fatal("expected negotiated tls details to be captured") + } } func TestQuery_InvalidEndpoint(t *testing.T) { @@ -75,6 +78,32 @@ func TestResolveTimeouts_Defaults(t *testing.T) { } } +func TestBuildConfig_ALPNProtocols(t *testing.T) { + config, err := buildConfig(QueryOptions{ALPNProtocols: []string{"h2", "http/1.1"}}) + if err != nil { + t.Fatalf("buildConfig failed: %v", err) + } + if got, want := strings.Join(config.NextProtos, ","), "h2,http/1.1"; got != want { + t.Fatalf("NextProtos = %q, want %q", got, want) + } +} + +func TestQuery_NegotiatedALPN(t *testing.T) { + server, addr := startTestTLSServerWithNextProtos(t, []string{"h2", "http/1.1"}) + defer server.Close() + + chain, err := Query(addr, QueryOptions{ALPNProtocols: []string{"h2", "http/1.1"}}) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if chain.NegotiatedTLS == nil { + t.Fatal("expected negotiated tls details") + } + if chain.NegotiatedTLS.ALPN != "h2" { + t.Fatalf("expected negotiated ALPN h2, got %q", chain.NegotiatedTLS.ALPN) + } +} + func TestQuery_HandshakeTimeout(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -143,6 +172,10 @@ func TestCertType(t *testing.T) { } func startTestTLSServer(t *testing.T, clientAuth bool) (net.Listener, string) { + return startTestTLSServerWithNextProtos(t, nil) +} + +func startTestTLSServerWithNextProtos(t *testing.T, nextProtos []string) (net.Listener, string) { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -179,6 +212,7 @@ func startTestTLSServer(t *testing.T, clientAuth bool) (net.Listener, string) { tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, + NextProtos: nextProtos, } rawListener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/internal/tlsquery/types.go b/internal/tlsquery/types.go index 01bc457..0a7a2d1 100644 --- a/internal/tlsquery/types.go +++ b/internal/tlsquery/types.go @@ -62,11 +62,19 @@ type TLSVersionInfo struct { InsecureCipherSuites []string `json:"insecure_cipher_suites,omitempty" yaml:"insecure_cipher_suites,omitempty"` } +// HandshakeInfo holds the negotiated TLS details for the default connection. +type HandshakeInfo struct { + TLSVersion string `json:"tls_version" yaml:"tls_version"` + CipherSuite string `json:"cipher_suite" yaml:"cipher_suite"` + ALPN string `json:"alpn,omitempty" yaml:"alpn,omitempty"` +} + // ChainInfo holds the full certificate chain. type ChainInfo struct { Certificates []CertInfo `json:"certificates" yaml:"certificates"` Verified bool `json:"verified" yaml:"verified"` VerificationError string `json:"verification_error,omitempty" yaml:"verification_error,omitempty"` + NegotiatedTLS *HandshakeInfo `json:"negotiated_tls,omitempty" yaml:"negotiated_tls,omitempty"` TLSVersions []TLSVersionInfo `json:"tls_versions,omitempty" yaml:"tls_versions,omitempty"` InputName string `json:"-" yaml:"-"` InputLabel string `json:"-" yaml:"-"` @@ -77,6 +85,7 @@ type QueryOptions struct { CACertFile string // Path to custom CA certificate file (PEM format) Proxy string // Proxy URL (e.g. http://proxy:8080). If empty, HTTPS_PROXY/HTTP_PROXY env vars are used. TLSVersions bool // Probe and display supported TLS versions. + ALPNProtocols []string // Advertised ALPN protocols for the TLS handshake. ServerName string // SNI override for TLS handshake (useful when connecting by IP). StartTLS string // STARTTLS protocol: smtp, imap, pop3, ldap. ConnectTimeout time.Duration // TCP connect timeout. Defaults to DefaultConnectTimeout when unset.