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
14 changes: 8 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **Breaking:** Default `TrustStore` in `DefaultOptions()` changed from `"system"` to `"mozilla"` — pure-Go Mozilla root verification is used by default instead of macOS `SecTrustEvaluateWithError` syscalls, eliminating multi-minute hangs on large certificate stores
- Parallelize trust verification in scan summary, dump-certs, and AIA resolution — mozilla checks run concurrently, system checks only run for certs mozilla didn't trust
- Default `scan`, `verify`, `inspect`, and `connect` trust-store selection to Mozilla, and require an explicit `--trust-store system` when command trust reporting should come from host trust roots
- Parallelize trust verification in scan summary, dump-certs, and AIA resolution for the selected trust store
- Add `TrustStore` label to `VerifyChainTrustInput` and debug-log every trust verification call with subject, store, and result
- Normalize all exported private key PEM output (`.key`, K8s `tls.key`, YAML `key`) to PKCS#8 (`PRIVATE KEY`) regardless of input format ([#167])
- Bundle export warns when Kubernetes TLS secret contains an unencrypted private key alongside encrypted outputs ([#167])
- Use browser Web Crypto API for PBKDF2 key derivation in WASM builds to avoid blocking the main thread during encrypted key export ([#167])
- `verify` now checks both Mozilla and system trust stores by default and treats a certificate as trusted when any available anchor source succeeds ([`0ee41ad`])
- `verify` now uses the selected built-in trust store plus any `--roots` file-backed anchors, and treats a certificate as trusted when any requested anchor source succeeds
- `scan` now counts `untrusted_*` certificates as trusted by neither Mozilla nor system, and exposes per-store trust counts in JSON output ([`0ee41ad`])
- Surface trust-source load warnings in `inspect`, `verify`, and `connect`, fail fast on invalid `verify` trust-store configuration, and stop reporting a synthetic `file` source when no file-backed roots were requested ([#171])

### Removed

- **Breaking:** Remove `verify --trust-store`; use the default Mozilla+system verification or `--roots` to add a file-backed trust source ([`0ee41ad`])

### Fixed

- Make `scan` summaries rely on the selected trust store while still retrying bundle exports against host trust roots after Mozilla unknown-authority failures
- Stop assigning fallback bundle names to certificates that do not match any configured bundle entry, so stray export directories like `bundles/spf-console.zimperium.com/` are no longer generated
- Preserve the certificate common name in generated bundle CSRs so exported requests include a subject CN alongside SANs
- Include `subject.common_name` in generated CSR JSON output so `.csr.json` matches the exported CSR subject
- Stop bundle export from retrying the system trust fallback for non-trust-store verification failures such as expired certificates
- 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])
Expand Down
56 changes: 30 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,47 +142,50 @@ Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always
### Inspect Flags

<!-- certkit:flags:inspect -->
| Flag | Default | Description |
| ------------------------- | ------- | ----------------------------------------------- |
| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints |
| `--format` | `text` | Output format: text, json |
| Flag | Default | Description |
| ------------------------- | --------- | ----------------------------------------------- |
| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints |
| `--format` | `text` | Output format: text, json |
| `--trust-store` | `mozilla` | Trust store: system, mozilla |
<!-- /certkit:flags -->

JSON certificate records include `trust_anchors` and `trust_warnings`.

### Verify Flags

<!-- certkit:flags:verify -->
| Flag | Default | Description |
| ------------------------- | ------- | --------------------------------------------------------------------- |
| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints |
| `--crl` | `false` | Check CRL distribution points for revocation |
| `--diagnose` | `false` | Show diagnostics when chain verification fails |
| `--expiry`, `-e` | | Check if cert expires within duration (e.g., 30d, 720h) |
| `--format` | `text` | Output format: text, json |
| `--key` | | Private key file to check against the certificate |
| `--ocsp` | `false` | Check OCSP revocation status |
| `--roots` | | Additional root certificates file (PEM, DER, PKCS#7, PKCS#12, or JKS) |
| Flag | Default | Description |
| ------------------------- | --------- | --------------------------------------------------------------------- |
| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints |
| `--crl` | `false` | Check CRL distribution points for revocation |
| `--diagnose` | `false` | Show diagnostics when chain verification fails |
| `--expiry`, `-e` | | Check if cert expires within duration (e.g., 30d, 720h) |
| `--format` | `text` | Output format: text, json |
| `--key` | | Private key file to check against the certificate |
| `--ocsp` | `false` | Check OCSP revocation status |
| `--roots` | | Additional root certificates file (PEM, DER, PKCS#7, PKCS#12, or JKS) |
| `--trust-store` | `mozilla` | Trust store: system, mozilla |
<!-- /certkit:flags -->

Chain verification is always performed against both the embedded Mozilla roots and the host system trust store. Use `--roots` to add a file-backed trust source for private PKI, including pinned or legacy trust anchors loaded from PEM, DER, PKCS#7, PKCS#12, or JKS. When the input contains an embedded private key (PKCS#12, JKS), key match is checked automatically. Use `--ocsp` and/or `--crl` to check revocation status (requires network access and a valid chain).
Chain verification uses the embedded Mozilla roots by default; use `--trust-store system` to switch to the host trust store. Use `--roots` to add a file-backed trust source for private PKI, including pinned or legacy trust anchors loaded from PEM, DER, PKCS#7, PKCS#12, or JKS. When the input contains an embedded private key (PKCS#12, JKS), key match is checked automatically. Use `--ocsp` and/or `--crl` to check revocation status (requires network access and a valid chain).

JSON output includes `trust_anchors` and `trust_warnings` for the leaf and displayed chain entries.

### Connect Flags

<!-- certkit:flags:connect -->
| Flag | Default | Description |
| ------------------------- | ------- | ----------------------------------------------------------------------------------- |
| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints |
| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings |
| `--crl` | `false` | Check CRL distribution points for revocation |
| `--fips-140-2` | `false` | Apply conservative FIPS 140-2 heuristic checks to negotiated/offered TLS algorithms |
| `--fips-140-3` | `false` | Apply conservative FIPS 140-3 heuristic checks to negotiated/offered TLS algorithms |
| `--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) |
| Flag | Default | Description |
| ------------------------- | --------- | ----------------------------------------------------------------------------------- |
| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints |
| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings |
| `--crl` | `false` | Check CRL distribution points for revocation |
| `--fips-140-2` | `false` | Apply conservative FIPS 140-2 heuristic checks to negotiated/offered TLS algorithms |
| `--fips-140-3` | `false` | Apply conservative FIPS 140-3 heuristic checks to negotiated/offered TLS algorithms |
| `--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) |
| `--trust-store` | `mozilla` | Trust store: system, mozilla |
<!-- /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 Expand Up @@ -267,6 +270,7 @@ Input format is auto-detected.
| `--load-db` | | Load an existing database into memory before scanning |
| `--max-file-size` | `10485760` | Skip files larger than this size in bytes (0 to disable) |
| `--save-db` | | Save the in-memory database to disk after scanning |
| `--trust-store` | `mozilla` | Trust store: system, mozilla |
<!-- /certkit:flags -->

### Keygen Flags
Expand Down
61 changes: 41 additions & 20 deletions bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ type CheckTrustAnchorsInput struct {
Cert *x509.Certificate
Intermediates *x509.CertPool
FileRoots *x509.CertPool
TrustStore string
}

// CheckTrustAnchorsResult reports which trust sources validated a certificate
Expand Down Expand Up @@ -410,35 +411,55 @@ func verifyChainTrustChains(input VerifyChainTrustInput) ([][]*x509.Certificate,
}

// CheckTrustAnchors reports which trust sources validate the certificate.
// Results are returned in stable order: mozilla, system, file.
// Results are returned in stable order: selected trust store, then file.
func CheckTrustAnchors(input CheckTrustAnchorsInput) CheckTrustAnchorsResult {
if input.Cert == nil {
return CheckTrustAnchorsResult{Anchors: []string{}, Warnings: []string{}}
}

result := CheckTrustAnchorsResult{
Anchors: make([]string, 0, 3),
Anchors: make([]string, 0, 2),
Warnings: make([]string, 0, 2),
}
if mozillaPool, err := MozillaRootPool(); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("mozilla trust source unavailable: %v", err))
} else if VerifyChainTrust(VerifyChainTrustInput{
Cert: input.Cert,
Roots: mozillaPool,
Intermediates: input.Intermediates,
TrustStore: "mozilla",
}) {
result.Anchors = append(result.Anchors, "mozilla")
checkRoots := func(name string, roots *x509.CertPool) {
if VerifyChainTrust(VerifyChainTrustInput{
Cert: input.Cert,
Roots: roots,
Intermediates: input.Intermediates,
TrustStore: name,
}) {
result.Anchors = append(result.Anchors, name)
}
}
if systemPool, err := SystemCertPoolCached(); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("system trust source unavailable: %v", err))
} else if VerifyChainTrust(VerifyChainTrustInput{
Cert: input.Cert,
Roots: systemPool,
Intermediates: input.Intermediates,
TrustStore: "system",
}) {
result.Anchors = append(result.Anchors, "system")

trustStore := input.TrustStore
if trustStore == "" {
trustStore = "mozilla"
}
switch trustStore {
case "mozilla":
mozillaPool, err := MozillaRootPool()
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("mozilla trust source unavailable: %v", err))
break
}
checkRoots("mozilla", mozillaPool)
case "system":
systemPool, err := SystemCertPoolCached()
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("system trust source unavailable: %v", err))
break
}
checkRoots("system", systemPool)
case "custom":
result.Warnings = append(result.Warnings, "custom trust store cannot be evaluated without an explicit roots pool")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Suppress custom-store warning when file roots are present

When CheckTrustAnchors is called with TrustStore: "custom" and a non-nil FileRoots pool (for example via VerifyCert with custom roots), this branch always adds the warning that no explicit roots pool was provided. The same call can still append "file" to trust_anchors, so output becomes self-contradictory (trust_anchors shows success while trust_warnings claims roots are missing), which can trigger false failures in warning-sensitive tooling.

Useful? React with 👍 / 👎.

case "file":
if input.FileRoots == nil {
result.Warnings = append(result.Warnings, "file trust store cannot be evaluated without file roots")
}
default:
result.Warnings = append(result.Warnings, fmt.Sprintf("unsupported trust store %q", trustStore))
return result

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

CheckTrustAnchors returns early for unsupported TrustStore values, which prevents FileRoots from being evaluated even when input.FileRoots is provided. Consider recording the warning but continuing so file-backed roots can still produce a "file" anchor (or explicitly document that unsupported TrustStore disables all trust evaluation).

Suggested change
return result

Copilot uses AI. Check for mistakes.
}
if input.FileRoots != nil && VerifyChainTrust(VerifyChainTrustInput{
Cert: input.Cert,
Expand Down
44 changes: 44 additions & 0 deletions bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,50 @@ func TestCheckTrustAnchors_FileRoots(t *testing.T) {
}
}

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

_, intermediates, leaf := buildChain(t, 3)
intermediatePool := x509.NewCertPool()
for _, intermediate := range intermediates {
intermediatePool.AddCert(intermediate)
}

result := CheckTrustAnchors(CheckTrustAnchorsInput{
Cert: leaf,
Intermediates: intermediatePool,
TrustStore: "custom",
})
if len(result.Anchors) != 0 {
t.Fatalf("CheckTrustAnchors() anchors = %v, want none", result.Anchors)
}
if got, want := strings.Join(result.Warnings, "; "), "custom trust store cannot be evaluated without an explicit roots pool"; got != want {
t.Fatalf("CheckTrustAnchors() warnings = %q, want %q", got, want)
}
}

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

_, intermediates, leaf := buildChain(t, 3)
intermediatePool := x509.NewCertPool()
for _, intermediate := range intermediates {
intermediatePool.AddCert(intermediate)
}

result := CheckTrustAnchors(CheckTrustAnchorsInput{
Cert: leaf,
Intermediates: intermediatePool,
TrustStore: "file",
})
if len(result.Anchors) != 0 {
t.Fatalf("CheckTrustAnchors() anchors = %v, want none", result.Anchors)
}
if got, want := strings.Join(result.Warnings, "; "), "file trust store cannot be evaluated without file roots"; got != want {
t.Fatalf("CheckTrustAnchors() warnings = %q, want %q", got, want)
}
}

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

Expand Down
Loading
Loading