Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `verify --roots <file>` to include PEM/DER/PKCS#7/PKCS#12/JKS certificates as an additional file-backed trust source ([`0ee41ad`])
- Add `CheckTrustAnchorsResult` so library callers can inspect `trust_anchors` plus trust-source load warnings from `CheckTrustAnchors` ([#171])
- Display top-level X.509 certificate extensions with human-readable OID names in `inspect`, `verify --verbose`, and `connect --verbose`, including critical and unhandled-critical markers ([`512d96c`])
- Add `connect --tls-version` to pin the negotiated protocol version when comparing server TLS behavior across TLS 1.0-1.3 ([#190])

### Changed

Expand All @@ -36,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Make `connect` fail when the peer omits part of the trust path and validation only succeeds after local chain completion, instead of accepting the incomplete server-presented chain ([#190])
- Use bundle folder name as Kubernetes secret `metadata.name` instead of the CN-derived prefix, so the secret name matches the export directory ([#178])
- Validate `bundleName` in bundle config YAML against DNS-1123 rules at load time; invalid names now produce a fatal error with the file path and line number ([#178])
- Deduplicate extension block formatting between `connect --verbose` and the shared internal formatter to prevent drift in extension flag rendering ([`30d67f8`])
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ JSON output includes `trust_anchors` and `trust_warnings` for the leaf and displ
| `--format` | `text` | Output format: text, json |
| `--no-ocsp` | `false` | Disable automatic OCSP revocation check |
| `--servername` | | Override SNI hostname (defaults to host) |
| `--tls-version` | | Pin TLS version: 1.0, 1.1, 1.2, or 1.3 (default: auto) |
Comment thread
danielewood marked this conversation as resolved.
<!-- /certkit:flags -->

Port defaults to 443 if not specified. OCSP revocation status is checked automatically (best-effort); use `--no-ocsp` to disable. Use `--verbose` for extended details (serial, key info, signature algorithm, key usage, EKU, extensions) plus a PEM-formatted copy of the server-sent certificate chain with `# Subject`, `# Issuer`, and validity headers.
Expand Down
41 changes: 41 additions & 0 deletions cmd/certkit/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
Expand All @@ -25,6 +26,7 @@ import (
var (
connectServerName string
connectFormat string
connectTLSVersion string
connectCRL bool
connectNoOCSP bool
connectCiphers bool
Expand All @@ -34,6 +36,7 @@ var (
errConnectEmptyHost = errors.New("empty host")
errConnectEmptyPort = errors.New("empty port")
errConnectInvalidPort = errors.New("invalid port")
errConnectUnsupportedTLS = errors.New("unsupported TLS version")
)

var connectCmd = &cobra.Command{
Expand All @@ -50,9 +53,13 @@ the server supports with security ratings.
Network fetches for AIA/OCSP/CRL block private/internal endpoints by default.
Use --allow-private-network to opt in for internal PKI environments.

Use --tls-version 1.2 or --tls-version 1.3 to compare version-specific
certificate presentation and validation behavior.

Exits with code 2 if chain verification fails or the certificate is revoked.`,
Example: ` certkit connect example.com
certkit connect example.com:8443
certkit connect example.com --tls-version 1.2
certkit connect example.com --crl
certkit connect example.com --ciphers
certkit connect example.com --servername alt.example.com
Expand All @@ -64,6 +71,7 @@ Exits with code 2 if chain verification fails or the certificate is revoked.`,
func init() {
connectCmd.Flags().StringVar(&connectServerName, "servername", "", "Override SNI hostname (defaults to host)")
connectCmd.Flags().StringVar(&connectFormat, "format", "text", "Output format: text, json")
connectCmd.Flags().StringVar(&connectTLSVersion, "tls-version", "", "Pin TLS version: 1.0, 1.1, 1.2, or 1.3 (default: auto)")
connectCmd.Flags().BoolVar(&connectCRL, "crl", false, "Check CRL distribution points for revocation")
connectCmd.Flags().BoolVar(&connectNoOCSP, "no-ocsp", false, "Disable automatic OCSP revocation check")
connectCmd.Flags().BoolVar(&connectCiphers, "ciphers", false, "Enumerate all supported cipher suites with security ratings")
Expand All @@ -72,6 +80,7 @@ func init() {
connectCmd.Flags().BoolVar(&connectAllowPrivateNetwork, "allow-private-network", false, "Allow AIA/OCSP/CRL fetches to private/internal endpoints")

registerCompletion(connectCmd, completionInput{"format", fixedCompletion("text", "json")})
registerCompletion(connectCmd, completionInput{"tls-version", fixedCompletion("1.0", "1.1", "1.2", "1.3")})
}

// connectResultJSON is a JSON-serializable version of ConnectResult.
Expand Down Expand Up @@ -127,6 +136,10 @@ func runConnect(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("parsing address %q: %w", args[0], err)
}
version, err := parseConnectTLSVersion(connectTLSVersion)
if err != nil {
return fmt.Errorf("parsing --tls-version: %w", err)
}
policy, err := selectedPolicy(connectFIPS1402, connectFIPS1403)
if err != nil {
return err
Expand All @@ -141,6 +154,7 @@ func runConnect(cmd *cobra.Command, args []string) error {
result, err := certkit.ConnectTLS(ctx, certkit.ConnectTLSInput{
Host: host,
Port: port,
Version: version,
ServerName: connectServerName,
DisableOCSP: connectNoOCSP,
CheckCRL: connectCRL,
Expand Down Expand Up @@ -317,6 +331,23 @@ func runConnect(cmd *cobra.Command, args []string) error {
return nil
}

func parseConnectTLSVersion(raw string) (uint16, error) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "", "auto":
return 0, nil
case "1.0", "tls1.0", "tls1":
return tls.VersionTLS10, nil
case "1.1", "tls1.1":
return tls.VersionTLS11, nil
case "1.2", "tls1.2":
return tls.VersionTLS12, nil
case "1.3", "tls1.3":
return tls.VersionTLS13, nil
default:
return 0, fmt.Errorf("%w: %q (use auto, 1.0, 1.1, 1.2, or 1.3)", errConnectUnsupportedTLS, raw)
}
}

// formatConnectVerbose formats a ConnectResult with extended certificate details.
func formatConnectVerbose(r *certkit.ConnectResult, now time.Time) string {
var out strings.Builder
Expand Down Expand Up @@ -417,6 +448,16 @@ func formatConnectChainPEM(chain []*x509.Certificate) string {
}

func collectConnectTrustStatus(result *certkit.ConnectResult) ([][]string, [][]string) {
if result.TrustPathStatus == certkit.ConnectTrustPathStatusPresentedInvalid && len(result.VerifiedChains) == 0 {
anchors := make([][]string, len(result.PeerChain))
warnings := make([][]string, len(result.PeerChain))
for i := range result.PeerChain {
anchors[i] = []string{}
warnings[i] = []string{}
}
return anchors, warnings
}

intermediates := connectTrustIntermediates(result)

anchors := make([][]string, 0, len(result.PeerChain))
Expand Down
60 changes: 60 additions & 0 deletions cmd/certkit/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
Expand Down Expand Up @@ -134,6 +135,45 @@ func TestRunConnect_InvalidPortInput(t *testing.T) {
}
}

func TestParseConnectTLSVersion(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
want uint16
wantErrContains string
}{
{name: "auto default", input: "", want: 0},
{name: "auto explicit", input: "auto", want: 0},
{name: "tls12", input: "1.2", want: tls.VersionTLS12},
{name: "tls13 alias", input: "tls1.3", want: tls.VersionTLS13},
{name: "invalid", input: "1.4", wantErrContains: `unsupported TLS version: "1.4"`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseConnectTLSVersion(tt.input)
if tt.wantErrContains != "" {
if err == nil {
t.Fatalf("parseConnectTLSVersion(%q) expected error", tt.input)
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Fatalf("parseConnectTLSVersion(%q) error = %q, want substring %q", tt.input, err.Error(), tt.wantErrContains)
}
return
}
if err != nil {
t.Fatalf("parseConnectTLSVersion(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseConnectTLSVersion(%q) = 0x%04x, want 0x%04x", tt.input, got, tt.want)
}
})
}
}

func TestConnectTextStatusSectionConsistency(t *testing.T) {
// WHY: shared status lines must stay identical between standard and verbose
// connect output to prevent contract drift.
Expand All @@ -145,6 +185,7 @@ func TestConnectTextStatusSectionConsistency(t *testing.T) {
Protocol: "TLS 1.3",
CipherSuite: "TLS_AES_128_GCM_SHA256",
ServerName: "test.example.com",
VerifyError: "x509: certificate signed by unknown authority",
ALPN: "h2",
AIAFetched: true,
CT: &certkit.CTResult{Status: "ok", Total: 3, Valid: 3},
Expand Down Expand Up @@ -191,6 +232,25 @@ func TestConnectTextStatusSectionConsistency(t *testing.T) {
}
}

func TestCollectConnectTrustStatus_PresentedTrustPathInvalid(t *testing.T) {
t.Parallel()

result := &certkit.ConnectResult{
PeerChain: []*x509.Certificate{{}},
TrustPathStatus: certkit.ConnectTrustPathStatusPresentedInvalid,
VerifiedChains: nil,
ChainTrustAnchors: nil,
}

anchors, warnings := collectConnectTrustStatus(result)
if len(anchors) != 1 || len(anchors[0]) != 0 {
t.Fatalf("anchors = %#v, want one empty entry", anchors)
}
if len(warnings) != 1 || len(warnings[0]) != 0 {
t.Fatalf("warnings = %#v, want one empty entry", warnings)
}
}

func TestFormatConnectVerbose_IncludesChainPEMWithMetadata(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading