Skip to content

feat: add key_exchange field for negotiated TLS key exchange group#948

Open
usernametooshort wants to merge 1 commit intoprojectdiscovery:mainfrom
usernametooshort:feat/key-exchange-field
Open

feat: add key_exchange field for negotiated TLS key exchange group#948
usernametooshort wants to merge 1 commit intoprojectdiscovery:mainfrom
usernametooshort:feat/key-exchange-field

Conversation

@usernametooshort
Copy link

@usernametooshort usernametooshort commented Mar 7, 2026

Closes #935.

Summary

crypto/tls ConnectionState.CurveID has been populated after the handshake since Go 1.24. This PR exposes it in tlsx output as key_exchange (JSON) and via a new -ke / --key-exchange CLI flag.

In TLS 1.3, the cipher suite name no longer encodes the key exchange mechanism — it is negotiated separately. Without this field, there is no way to distinguish a post-quantum secured connection from a classical one when scanning at scale.

Changes

  • pkg/tlsx/clients/clients.go — add KeyExchange bool option and KeyExchange string to Response
  • pkg/tlsx/tls/tls.go — read connectionState.CurveID.String() and populate response.KeyExchange
  • cmd/tlsx/main.go — add -ke / --key-exchange flag
  • pkg/output/output.go — display in text output (cyan, alongside cipher)
  • internal/runner/banner.go — include KeyExchange in probeSpecified

Usage

# Text output
tlsx -host cloudflare.com,github.com -ke -cipher -tls-version
# cloudflare.com [tls13] [TLS_AES_128_GCM_SHA256] [X25519MLKEM768]
# github.com     [tls13] [TLS_AES_128_GCM_SHA256] [X25519]

# JSON output (always included when field is non-empty)
tlsx -host cloudflare.com -json | jq .key_exchange
# "X25519MLKEM768"

Notes

  • Only populated for the ctls (standard crypto/tls) client; ztls does not expose CurveID in its ServerHello struct
  • CurveID.String() in Go 1.24+ handles all known groups including X25519MLKEM768 (CurveID 4588)
  • Field is omitted from JSON when empty (TLS < 1.3 or ztls path)
  • Pre-existing TestClientCertRequired failure in tls and ztls packages is unrelated to this PR (TLS 1.0 handshake rejection on Go 1.25)

Summary by CodeRabbit

  • New Features
    • Added reporting of the negotiated key exchange group in TLS handshake information, enabling visibility into the cryptographic parameters used during secure connections.

@neo-by-projectdiscovery-dev
Copy link

neo-by-projectdiscovery-dev bot commented Mar 7, 2026

Neo - PR Security Review

Caution

Review could not be completed

Review could not be completed. Please retry with @neo review.

Suggestion: Try again with @neo review.

Comment @neo help for available commands.

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

Walkthrough

Added a new KeyExchange field to the Response struct to capture the negotiated TLS key exchange group. The TLS client now reads the CurveID from the connection state after handshake, converts it to a human-readable string using a helper function, and populates this field in the JSON output.

Changes

Cohort / File(s) Summary
Response Struct Enhancement
pkg/tlsx/clients/clients.go
Added KeyExchange string field with json tag "key_exchange,omitempty" to expose negotiated TLS curves in output.
TLS CurveID Extraction & Mapping
pkg/tlsx/tls/tls.go
Added fmt import, introduced logic in ConnectWithOptions to read ConnectionState.CurveID and populate KeyExchange field; added curveIDToString helper function with mappings for common curves (X25519, X25519MLKEM768, CurveP256, etc.) and fallback handling for unmapped curve IDs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A curves are caught and cards reviewed,
The tails of TLS now include,
Key exchanges in the JSON light,
Post-quantum hops feel oh-so-right!
X25519 and P256 unite,
The audit's burrow burns more bright.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive The raw_summary shows only KeyExchange field additions to clients.go and tls.go. However, PR objectives mention changes to cmd/tlsx/main.go, pkg/output/output.go, and internal/runner/banner.go which are not reflected in the provided raw_summary. Clarify whether the raw_summary is complete or if additional files (main.go, output.go, banner.go) have changes. Verify all modifications align with issue #935 requirements.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a key_exchange field to capture the negotiated TLS key exchange group.
Linked Issues check ✅ Passed The PR adds the KeyExchange field to Response struct and populates it with CurveID data via curveIDToString helper, directly addressing issue #935's core requirement.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/runner/banner.go`:
- Line 34: The probeSpecified predicate in banner.go is missing several probe
flags (e.g., RespOnly, Serial, SAN, ProbeStatus), so update the expression that
builds probeSpecified (the line using r.options.SO || r.options.TLSVersion ||
...) to also OR in r.options.RespOnly, r.options.Serial, r.options.SAN, and
r.options.ProbeStatus (and any other probe boolean flags introduced on the
options struct) so combinations like --resp-only/--serial/--san/--probe-status
are treated as probes and trigger the same validation logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 22fddda5-3676-4ff3-a4f6-6b066afc4ac2

📥 Commits

Reviewing files that changed from the base of the PR and between d13b67f and 3eb80e5.

📒 Files selected for processing (5)
  • cmd/tlsx/main.go
  • internal/runner/banner.go
  • pkg/output/output.go
  • pkg/tlsx/clients/clients.go
  • pkg/tlsx/tls/tls.go

r.options.Retries = 1
}
probeSpecified := r.options.SO || r.options.TLSVersion || r.options.Cipher || r.options.Expired || r.options.SelfSigned || r.options.Hash != "" || r.options.Jarm || r.options.MisMatched || r.options.Revoked || r.options.WildcardCertCheck
probeSpecified := r.options.SO || r.options.TLSVersion || r.options.Cipher || r.options.KeyExchange || r.options.Expired || r.options.SelfSigned || r.options.Hash != "" || r.options.Jarm || r.options.MisMatched || r.options.Revoked || r.options.WildcardCertCheck
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

probeSpecified is still incomplete.

This predicate still misses several probe flags, so combinations like --resp-only --serial or --san --probe-status bypass the validation below even though the error text says SAN/CN and resp-only can't be mixed with other probes.

Suggested fix
-probeSpecified := r.options.SO || r.options.TLSVersion || r.options.Cipher || r.options.KeyExchange || r.options.Expired || r.options.SelfSigned || r.options.Hash != "" || r.options.Jarm || r.options.MisMatched || r.options.Revoked || r.options.WildcardCertCheck
+probeSpecified := r.options.SO || r.options.TLSVersion || r.options.Cipher || r.options.KeyExchange || r.options.ProbeStatus || r.options.Expired || r.options.SelfSigned || r.options.Untrusted || r.options.Hash != "" || r.options.Jarm || r.options.Ja3 || r.options.Ja3s || r.options.MisMatched || r.options.Revoked || r.options.WildcardCertCheck || r.options.TlsVersionsEnum || r.options.TlsCiphersEnum || r.options.Serial
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
probeSpecified := r.options.SO || r.options.TLSVersion || r.options.Cipher || r.options.KeyExchange || r.options.Expired || r.options.SelfSigned || r.options.Hash != "" || r.options.Jarm || r.options.MisMatched || r.options.Revoked || r.options.WildcardCertCheck
probeSpecified := r.options.SO || r.options.TLSVersion || r.options.Cipher || r.options.KeyExchange || r.options.ProbeStatus || r.options.Expired || r.options.SelfSigned || r.options.Untrusted || r.options.Hash != "" || r.options.Jarm || r.options.Ja3 || r.options.Ja3s || r.options.MisMatched || r.options.Revoked || r.options.WildcardCertCheck || r.options.TlsVersionsEnum || r.options.TlsCiphersEnum || r.options.Serial
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runner/banner.go` at line 34, The probeSpecified predicate in
banner.go is missing several probe flags (e.g., RespOnly, Serial, SAN,
ProbeStatus), so update the expression that builds probeSpecified (the line
using r.options.SO || r.options.TLSVersion || ...) to also OR in
r.options.RespOnly, r.options.Serial, r.options.SAN, and r.options.ProbeStatus
(and any other probe boolean flags introduced on the options struct) so
combinations like --resp-only/--serial/--san/--probe-status are treated as
probes and trigger the same validation logic.

Read ConnectionState.CurveID after TLS handshake and expose it
as key_exchange in JSON output. Supports TLS 1.3 post-quantum
key exchange detection (X25519MLKEM768 etc).

Closes projectdiscovery#935
@usernametooshort usernametooshort force-pushed the feat/key-exchange-field branch from 3eb80e5 to cb29af6 Compare March 7, 2026 07:29
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
pkg/tlsx/tls/tls.go (1)

304-329: Use tls.CurveID constants instead of magic numbers and remove the redundant condition.

The function can be improved for maintainability and clarity:

  1. Magic numbers: Replace numeric literals (23, 24, 25, 29) with the predefined constants (tls.CurveP256, tls.CurveP384, tls.CurveP521, tls.X25519).
  2. Misleading comment: Lines 322–323 mention "Try using the built-in String() method" but the code doesn't actually use it.
  3. Redundant condition: After handling case 0:, the check if curveID > 0 in the default branch is always true (since tls.CurveID is uint16).
  4. Simplification: In Go 1.24, tls.CurveID.String() is available and returns proper names for known curves; use it for the default case instead of the manual formatting.
♻️ Proposed refactor
 // curveIDToString converts CurveID to a human-readable string
 func curveIDToString(curveID tls.CurveID) string {
-	// Standard curve IDs from crypto/tls
+	if curveID == 0 {
+		return "" // No curve negotiated
+	}
+	// Handle known curves explicitly for consistency across Go versions
 	switch curveID {
-	case 23:
+	case tls.CurveP256:
 		return "CurveP256"
-	case 24:
+	case tls.CurveP384:
 		return "CurveP384"
-	case 25:
+	case tls.CurveP521:
 		return "CurveP521"
-	case 29:
+	case tls.X25519:
 		return "X25519"
 	case 4588:
+		// X25519MLKEM768 - post-quantum hybrid (Go 1.24+)
 		return "X25519MLKEM768"
-	case 0:
-		return "" // No curve negotiated
 	default:
-		// Try using the built-in String() method if available
-		// Otherwise return the numeric form
-		if curveID > 0 {
-			return fmt.Sprintf("CurveID(%d)", curveID)
-		}
-		return ""
+		return curveID.String()
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/tlsx/tls/tls.go` around lines 304 - 329, Replace magic numeric literals
in curveIDToString with the corresponding tls.CurveID constants (tls.CurveP256,
tls.CurveP384, tls.CurveP521, tls.X25519), remove the redundant if curveID > 0
check in the default branch, and call curveID.String() in the default case to
return the name for known curves (falling back to "" for tls.CurveID(0)); update
the comment about using the built-in String() method to reflect that it is
actually used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@pkg/tlsx/tls/tls.go`:
- Around line 304-329: Replace magic numeric literals in curveIDToString with
the corresponding tls.CurveID constants (tls.CurveP256, tls.CurveP384,
tls.CurveP521, tls.X25519), remove the redundant if curveID > 0 check in the
default branch, and call curveID.String() in the default case to return the name
for known curves (falling back to "" for tls.CurveID(0)); update the comment
about using the built-in String() method to reflect that it is actually used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 28ccdd51-1993-4549-8ef1-032e83b184fe

📥 Commits

Reviewing files that changed from the base of the PR and between 3eb80e5 and cb29af6.

📒 Files selected for processing (2)
  • pkg/tlsx/clients/clients.go
  • pkg/tlsx/tls/tls.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/tlsx/clients/clients.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the negotiated key exchange group (TLS curve) to tlsx JSON output.

1 participant