Skip to content

feat: add cipher suite enumeration with raw TLS 1.3, QUIC, and key exchange probing#82

Merged
danielewood merged 30 commits intomainfrom
feat/connect-cipher-audit
Feb 28, 2026
Merged

feat: add cipher suite enumeration with raw TLS 1.3, QUIC, and key exchange probing#82
danielewood merged 30 commits intomainfrom
feat/connect-cipher-audit

Conversation

@danielewood
Copy link
Collaborator

Summary

  • Add --ciphers flag to connect that enumerates all cipher suites a server supports across TLS 1.0–1.3, with good/weak security ratings
  • Raw TLS 1.3 ClientHello prober (tls13probe.go) — byte-level packet construction, no shared state, no data races. Probes all 5 RFC 8446 cipher suites and 7 key exchange groups (including post-quantum hybrids)
  • QUIC v1 Initial packet prober (quicprobe.go) — HKDF key derivation, AES-128-GCM encryption, header protection per RFC 9001. Automatically probes UDP 443 alongside TCP
  • Output subgrouped by TLS version and key exchange type (ECDHE vs "RSA, no forward secrecy")
  • HelloRetryRequest detection via RFC 8446 §4.1.3 sentinel

Test plan

  • go test -race -count=1 ./... passes
  • go vet ./... passes
  • golangci-lint run passes
  • Manual test: certkit connect google.com --ciphers shows TCP ciphers, QUIC ciphers, and key exchange groups
  • Manual test: certkit connect gmail.com --ciphers shows "QUIC: not supported" (gmail.com rejects QUIC — confirmed with curl --http3-only)
  • Manual test: certkit connect google.com --ciphers --json produces valid JSON

🤖 Generated with Claude Code

danielewood and others added 2 commits February 27, 2026 00:34
…change probing

Add --ciphers flag to connect command that enumerates all cipher suites
a server supports across TLS 1.0–1.3, probes key exchange groups
(including post-quantum hybrids), and tests QUIC/UDP alongside TCP.

- Raw TLS 1.3 ClientHello prober (tls13probe.go): byte-level packet
  construction offering single cipher/group per probe, fully isolated
  with no shared state. Probes all 5 RFC 8446 suites and 7 named groups.
- QUIC v1 Initial packet prober (quicprobe.go): HKDF key derivation,
  AES-128-GCM encryption, header protection per RFC 9001.
- HelloRetryRequest detection via RFC 8446 §4.1.3 sentinel random.
- Cipher output subgrouped by TLS version and key exchange type
  (ECDHE vs RSA) with forward secrecy labels.
- QUIC section always visible when probed ("not supported" if rejected).
- Good/weak security ratings for all cipher suites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 27, 2026 05:36
Auto-update Go and npm dependencies on every commit to stay ahead of
security vulnerabilities, eliminating the need for dependabot PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Supersedes dependabot PR #81.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds cipher suite enumeration functionality to the connect command via a new --ciphers flag. The implementation includes three major components:

Changes:

  • Raw TLS 1.3 ClientHello prober with byte-level packet construction, eliminating data races from the previous linkname hack approach
  • QUIC v1 Initial packet prober supporting cipher suite detection over UDP/443 with full RFC 9001 compliance
  • Key exchange group probing for classical and post-quantum hybrid mechanisms (X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024)

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tls13probe.go Raw TLS 1.3 ClientHello packet builder and parser with isolated per-probe connections
quicprobe.go QUIC v1 Initial packet builder with HKDF key derivation and AES-128-GCM encryption
connect.go Main cipher scanning logic with concurrent probing, rating system, and result formatting
connect_test.go Comprehensive test coverage for rating, scanning, formatting, and round-trip operations
testhelpers_test.go New test helper for TLS servers with custom configs
cmd/certkit/connect.go CLI integration with spinner and 60-second timeout
cmd/certkit/spinner.go Terminal-aware progress indicator for long-running operations
go.mod Added go-isatty dependency for terminal detection
README.md, EXAMPLES.md, CHANGELOG.md Documentation updates
Files not reviewed (1)
  • web/package-lock.json: Language not supported

@claude

This comment has been minimized.

danielewood and others added 2 commits February 27, 2026 00:59
- Add bounds checks for DCID/SCID lengths in QUIC response parser
- Add varint decode guards to prevent infinite loops on malformed ACK frames
- Increase UDP read buffer from 4096 to 65535 bytes
- Add session ID length bounds check in TLS ServerHello parser
- Refactor probe functions to use cipherProbeInput struct (CS-5)
- Fix EXAMPLES.md cipher rating terminology ("strong" → "good")
- Consolidate tests per T-9/T-11/T-12, add parser edge case coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

This comment has been minimized.

@claude

This comment has been minimized.

@claude

This comment has been minimized.

@claude

This comment has been minimized.

danielewood and others added 2 commits February 27, 2026 01:17
Address adversarial review findings from PR #82:

- Guard QUIC varint uint64→int casts with uint64-space bounds checks
  to prevent truncation on malicious packets
- Fix ACK range loop inner break not propagating to outer frame parser
- Cap ACK rangeCount to plaintext length to prevent CPU exhaustion
- Remove double-wrapped error messages in connect CLI
- Initialize CipherScanResult nil slices to empty for JSON encoding
- Strengthen TestBuildQUICInitialPacket with header + round-trip decrypt
- Consolidate TestRateCipherSuite from 13 to 6 entries (T-12)
- Merge TestScanCipherSuites_KeyExchanges into TestScanCipherSuites (T-14)
- Fix brittle tls13Count != 3 assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 27, 2026 06:18
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 9 comments.

Files not reviewed (1)
  • web/package-lock.json: Language not supported

@claude

This comment has been minimized.

…spinner safety

- Rate TLS_AES_128_CCM_8_SHA256 (0x1305) as weak (IANA Not Recommended,
  truncated 8-byte auth tag)
- Return empty OverallRating when no ciphers detected (omitempty JSON)
- Guard FormatCipherScanResult against nil receiver
- Tie spinner goroutine to context.Context (CC-2), make Stop idempotent
  with sync.Once to prevent double-close panic
- Lowercase error strings per ERR-4: hello retry request, server hello,
  long header
- Add hkdfExpandLabelInput struct per CS-5
- Remove direct tests of unexported helpers per T-11 (buildClientHelloMsg,
  parseServerHello, buildQUICInitialPacket, probeTLS13Cipher) — all
  exercised through ScanCipherSuites
- Fix changelog refs: use PR [#82] instead of branch commit SHA
- Fix probeTimeout comment to accurately describe context inheritance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

This comment has been minimized.

…nner safety

- Add TLS_AES_128_CCM_8_SHA256 (0x1305) → CipherRatingWeak test case
  covering the only TLS 1.3 cipher rated weak (T-12)
- Replace in-branch commit SHAs (18ed288, 1adb9b5) with PR ref [#82]
  so changelog links survive squash merge (CL-3/CL-4)
- Move go-mod-update and npm-update pre-commit hooks to stages: [manual]
  to avoid unexpected dependency churn on every commit
- Guard spinner.Start() with sync.Once for idempotent calls
- Add slog.Debug for QUIC PADDING/PING frame skips (ERR-5)
- Document X25519-only limitation in probeTLS13Cipher comment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 27, 2026 08:40
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • web/package-lock.json: Language not supported

@claude

This comment has been minimized.

- Use slices.Concat instead of append for cipher suite slice
  concatenation to prevent potential mutation of stdlib return value
- Show "Cipher suites: none detected" when scan finds no supported
  suites instead of silent empty output
- Add nil and empty-ciphers test cases to TestFormatCipherScanResult
  with exact-match assertion (previously asserted nothing)
- Consolidate startTLSServer to delegate to startTLSServerWithConfig,
  eliminating duplicated accept-loop code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…, version return

- ERR-4: lowercase TLS/QUIC error strings across tls13probe.go, legacyprobe.go,
  quicprobe.go, and connect.go ("tls alert received", "tls record too large",
  "quic packet too short", "tls handshake with …")
- ERR-1: wrap bare return err at CLI connect boundary with fmt.Errorf context
- ERR-5: add slog.Debug before continue in QUIC ACK frame handler
- Naming: rename emptyClientCert → emptyClientCertificate per convention
- Bug: FormatCipherScanResult showed "none detected" for QUIC-only servers;
  empty check now covers both r.Ciphers and r.QUICCiphers
- Bug: probeLegacyCipher hardcoded "TLS 1.2" — now returns actual negotiated
  version from ServerHello; ScanCipherSuites uses tlsVersionString(negotiatedVer)
- T-11: remove TestBuildLegacyClientHelloMsg — behavioral coverage exists
  through TestLegacyFallbackConnect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@danielewood
Copy link
Collaborator Author

Fixed in 6492fa5:

  • ERR-5 (bare continue in ACK handler): added slog.Debug("skipping QUIC ACK frame") before the continue in quicprobe.go
  • ERR-4 (TLS/QUIC uppercase errors): addressed in comment 3975055985 — see fix there
  • CL-3/CL-4 (changelog [pending]): entries now reference commit 6492fa5
  • T-11 (TestBuildLegacyClientHelloMsg): removed — behavioral coverage exists through TestLegacyFallbackConnect
  • Bug (QUIC-only "none detected"): FormatCipherScanResult empty check now covers both r.Ciphers and r.QUICCiphers

CS-5 on appendKeyShareExtension left as-is — the b []byte parameter acts as an append-idiom receiver and adding a struct would obscure the byte-builder pattern.

@danielewood
Copy link
Collaborator Author

Fixed in 6492fa5 — all four error strings lowercased:

  • tls13probe.go: errAlertReceived = errors.New("tls alert received")
  • tls13probe.go + legacyprobe.go: "tls record too large: %d bytes"
  • quicprobe.go: "quic packet too short: %d bytes"
  • connect.go: "tls handshake with %s: %w"

@danielewood
Copy link
Collaborator Author

Fixed in 6492fa5 — renamed emptyClientCertemptyClientCertificate (all 3 occurrences in connect.go, including the function definition and both call sites).

@danielewood
Copy link
Collaborator Author

Fixed in 6492fa5:

  1. ERR-1 (bare return err in CLI): return fmt.Errorf("connecting to %s: %w", args[0], err)
  2. T-11 (TestBuildLegacyClientHelloMsg): removed
  3. Logic bug (probeLegacyCipher hardcoded version): now returns (uint16, bool) with the actual negotiated version; ScanCipherSuites uses tlsVersionString(negotiatedVer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 27, 2026 21:43
…y possession is not

The raw legacy probe path calls populateConnectResult which runs full
x509.Verify against the system root store, AIA walking, OCSP, and CRL checks.
The old "Verify: N/A (raw handshake — certificate not cryptographically verified)"
message was misleading — the chain IS cryptographically verified. What is NOT
verified is that the server possesses the private key for the certificate (no
TLS Finished message was exchanged).

Update: remove the LegacyProbe branch that suppressed Verify output; show the
real result (OK/FAILED). Update the Note line and the legacy-only diagnostic
to accurately describe what is and isn't verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • web/package-lock.json: Language not supported

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

This comment has been minimized.

@claude

This comment has been minimized.

…IC port

- Skip OCSP and CRL checks for legacy probes; revocation is not
  meaningful without an authenticated TLS channel. Eliminates
  misleading "OCSP: skipped (no issuer in chain)" output on raw
  handshake results. x509 chain verification is still performed.
- Guard legacy fallback behind tls.AlertError: only attempt raw
  DHE probe when the server sent a TLS alert (cipher negotiation
  failure), not for network errors or certificate errors that would
  add a spurious 5-second timeout.
- Restrict QUIC probing to port 443: non-443 ports cause spurious
  timeouts since QUIC is not conventionally served there.
- Fix TOCTOU in spinner.Stop(): remove started guard, use stopOnce
  unconditionally — works correctly whether Stop() races with Start()
  or not.
- Replace in-place diagnostics filter with a new slice allocation to
  eliminate aliasing confusion.
- Remove stale CHANGELOG entry saying QUIC is unrestricted (reversed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 27, 2026 23:28
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • web/package-lock.json: Language not supported

- Convert populateConnectResult to (*ConnectResult).populate — reduces
  argument count from 3 to 2 (ctx + input) per CS-5
- Remove TestParseCertificateMessage — behavioral coverage exists
  through TestReadServerCertificates (T-11)
- Remove TestCipherSuiteNameLegacyIDs — behavioral coverage exists
  through TestScanCipherSuites (T-11)
- Shorten full-SHA CHANGELOG link definitions to 7-char SHAs (CL-4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@danielewood
Copy link
Collaborator Author

Fixed in 264281eTestCipherSuiteNameLegacyIDs removed. Behavioral coverage exists through TestScanCipherSuites.

@danielewood
Copy link
Collaborator Author

Fixed in 264281e:

  1. CS-5 — converted populateConnectResult(ctx, result, input) to (*ConnectResult).populate(ctx, input), reducing argument count to 2 (ctx + input).
  2. T-11 — removed TestParseCertificateMessage; behavioral coverage exists through TestReadServerCertificates.
  3. T-11 — removed TestCipherSuiteNameLegacyIDs; behavioral coverage exists through TestScanCipherSuites.
  4. CL-4 — shortened full-40-char SHA URLs to 7-char SHAs in the link definitions.

@claude

This comment has been minimized.

@claude

This comment has been minimized.

…ntext

- Fix Stop() deadlock when called before Start(): use startOnce.Do to
  close done channel, ensuring <-s.done never blocks regardless of call order
- Include legacyErr in error message when TLS handshake + fallback both fail
- Update RateCipherSuite comment: clarify DHE/DSS have no modern FS guarantees
- Lowercase QUIC/TLS error strings in quicprobe.go, legacyprobe.go, tls13probe.go
  per ERR-4 (error strings must be lowercase, acronyms included)
- Extract appendKeyShareExtensionInput struct for appendKeyShareExtension per CS-5
- Fix ERR-5 in TestLegacyFallbackConnect: log Read/Write errors via slog.Debug
- Update test assertion for lowercased error string
Copilot AI review requested due to automatic review settings February 28, 2026 00:29
@danielewood
Copy link
Collaborator Author

Fixed in 0c24518:

  • ERR-4 (quicprobe.go): all 3 QUIC uppercase strings lowercased — deriving quic keys, decrypting quic payload, no crypto frame found in quic initial response
  • CS-5 (tls13probe.go): extracted appendKeyShareExtensionInput{groupID, keyData} struct; updated caller

@danielewood
Copy link
Collaborator Author

Fixed in 0c24518:

  • ERR-4 (quicprobe.go, legacyprobe.go, tls13probe.go): all uppercase QUIC/TLS occurrences in error strings lowercased (10 strings total)
  • ERR-5 (legacyprobe_test.go): _, _ = conn.Read/Write replaced with if _, err := ...; err != nil { slog.Debug(...) } pattern

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • web/package-lock.json: Language not supported

@claude
Copy link

claude bot commented Feb 28, 2026

Code review

ERR-5 (MUST): Silent error discard in all probe functions

Affected files: tls13probe.go, connect.go, legacyprobe.go, quicprobe.go

All six probe functions (probeTLS13Cipher, probeKeyExchangeGroup, probeCipher, probeKeyExchangeGroupLegacy, probeLegacyCipher, probeQUICCipher) contain bare return false statements after every error check, with no slog.Debug call explaining why the error is being discarded.

ERR-5 (MUST): "Never silently ignore errors. Use continue in loops only with a slog.Debug explaining why."

The first sentence is a general prohibition — not limited to continue-in-loops. The project consistently applies this rule to all error-consuming patterns: slog.Debug("OCSP check failed", ...), slog.Debug("CRL fetch failed", ...), slog.Debug("failed to unmarshal acceptable CA DN", ...) in connect.go are all non-loop cases that log before swallowing an error. The CHANGELOG entry for this PR even notes that ERR-5 was explicitly applied to slog.Debug before continue in the QUIC ACK frame handler — but the probe functions themselves have the same pattern unfixed.

The probe functions conflate two distinct outcomes into the same silent false:

  1. The server does not support the probed cipher/group (expected, normal)
  2. A genuine error: connection refused, DNS failure, malformed response, timeout

Without any logging, unexpected failures are invisible during debugging. Example from tls13probe.go:

// https://github.com/sensiblebit/certkit/blob/0c24518d10590e91e5384bc61ce97dc518a1f86b/tls13probe.go#L391-L395
conn, err := dialer.DialContext(ctx, "tcp", input.addr)
if err != nil {
    return false  // ERR-5: no slog.Debug
}

This pattern appears ~20 times across the four probe files. The fix is to add slog.Debug before each silent return false — for example:

conn, err := dialer.DialContext(ctx, "tcp", input.addr)
if err != nil {
    slog.Debug("tls13 cipher probe dial failed", "addr", input.addr, "cipher", input.cipherID, "error", err)
    return false
}

@danielewood danielewood merged commit df83a99 into main Feb 28, 2026
27 of 28 checks passed
@danielewood danielewood deleted the feat/connect-cipher-audit branch February 28, 2026 01:26
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.

2 participants