Skip to content

perf: parallelize trust verification and default to mozilla root store - #183

Merged
danielewood merged 3 commits into
mainfrom
perf/trust-verification-parallelism
Mar 23, 2026
Merged

perf: parallelize trust verification and default to mozilla root store#183
danielewood merged 3 commits into
mainfrom
perf/trust-verification-parallelism

Conversation

@danielewood

@danielewood danielewood commented Mar 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Breaking: Default TrustStore in DefaultOptions() changed from "system" to "mozilla" — eliminates multi-minute hangs on macOS when scanning large certificate stores
  • Parallelize trust verification across scan summary, --dump-certs, and AIA resolution using goroutines
  • Add TrustStore label to VerifyChainTrustInput with debug logging for every trust check

The Bug

On macOS, x509.Certificate.Verify with the system cert pool calls into the Security framework's SecTrustEvaluateWithError — a blocking syscall that performs OCSP/CRL network checks and can take seconds per certificate. The scan command was verifying every certificate sequentially against the system trust store:

goroutine 1 [syscall]:
  crypto/x509/internal/macos.SecTrustEvaluateWithError
  crypto/x509.(*Certificate).systemVerify
  certkit.VerifyChainTrust
  certkit.countAIAUnresolvedIssuers       ← called per-bundle during export
  certkit.Bundle
  certstore.ExportMatchedBundles
  internal.ExportBundles
  main.runScan

With ~2500 certificates and ~134,000 AIA resolution trust checks, the scan would hang indefinitely (killed after 10+ minutes with zero output).

Root Cause

Two compounding issues:

  1. DefaultOptions() used TrustStore: "system" — every Bundle() call during export verified certs against macOS SecTrust, which does network I/O per cert
  2. All trust checks were sequential — no parallelism in ScanSummary, countAIAUnresolvedIssuers, or --dump-certs

The Fix

1. Default to Mozilla (pure Go, no syscalls)

The embedded Mozilla root pool uses Go's x509.Verify with an in-memory cert pool — no system calls, no network I/O. This alone eliminates the hang.

// Before
TrustStore: "system"  // macOS SecTrustEvaluateWithError per cert

// After
TrustStore: "mozilla"  // pure Go x509.Verify — fast

2. Mozilla-first short-circuit

System trust checks now only run for certs that Mozilla didn't trust. Since most valid certs are Mozilla-trusted, this skips the expensive syscall for the majority:

Before: 134,000 system trust checks (all blocking syscalls)
After:    2,208 system trust checks (only mozilla-untrusted certs)

3. Parallel verification

All trust checks within a phase fire concurrently via goroutines:

  • Mozilla checks all run in parallel (pure Go, safe to fan out aggressively)
  • System checks run in parallel only for the mozilla-untrusted remainder

4. Debug observability

Every VerifyChainTrust call now logs subject, store name, and result at debug level:

level=DEBUG msg="verifying chain trust" subject=example.com store=mozilla
level=DEBUG msg="chain trust result" subject=example.com store=mozilla trusted=true

Results

Metric Before After
Scan time (~2500 certs) hung indefinitely ~45 seconds
System trust calls ~134,000 2,208
Mozilla trust calls 0 2,722
AIA resolve calls ~134,000 (system) ~134,000 (mozilla, pure Go)

Breaking Change

DefaultOptions().TrustStore is now "mozilla" instead of "system". Library callers that relied on the system trust store as default should explicitly set opts.TrustStore = "system".

Test plan

  • All existing tests pass (pre-commit run --all-files)
  • DefaultOptions() test updated for new default
  • Manual test: certkit scan <dir> --bundle-path <out> -l debug completes in ~45s with correct output
  • Debug logs confirm mozilla-first short-circuit (zero system calls when all certs are mozilla-trusted)

🤖 Generated with Claude Code

On macOS, x509.Certificate.Verify with the system cert pool calls
SecTrustEvaluateWithError — a blocking syscall that can take seconds
per certificate for OCSP/CRL checks. With large certificate stores
(2500+ certs), scan+export operations hung indefinitely because every
certificate was verified sequentially against the system trust store.

Three changes fix this:

1. Default TrustStore from "system" to "mozilla". The embedded Mozilla
   root pool uses pure-Go verification — no syscalls, no network I/O.
   This alone eliminates the hang for the common case.

2. Parallelize trust verification in ScanSummary, dump-certs, and
   countAIAUnresolvedIssuers. All mozilla checks fire concurrently
   via goroutines, then only certs that mozilla didn't trust fall
   through to the (slower) system trust check.

3. Add TrustStore label to VerifyChainTrustInput and debug-log every
   trust verification call with subject, store name, and result.
   This makes future performance diagnosis trivial with -l debug.

Before: scan of ~2500 certs hung indefinitely (>10 minutes, killed)
After:  same scan completes in ~45 seconds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 23, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown

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 improves certificate trust verification performance by defaulting library verification to the embedded Mozilla root store (avoiding slow macOS system trust calls) and parallelizing trust checks in key scan/export paths, while adding a TrustStore label for per-check debug observability.

Changes:

  • Change DefaultOptions().TrustStore default from "system" to "mozilla" (breaking) and update docs/tests/changelog accordingly.
  • Add TrustStore label to VerifyChainTrustInput and emit debug logs for each trust check.
  • Parallelize trust verification in scan summary and --dump-certs paths (mozilla-first, system fallback).

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
bundle.go Adds TrustStore label + debug logs to trust verification; defaults trust store to Mozilla; concurrent trust checks for AIA unresolved issuer counting.
bundle_test.go Updates DefaultOptions() test to expect "mozilla".
internal/certstore/memstore.go Parallelizes trust verification in ScanSummary (mozilla-first, then system fallback).
cmd/certkit/scan.go Parallelizes trust checks for --dump-certs filtering (mozilla-first, then system fallback).
internal/verify.go Threads TrustStore label through trust checks for AIA-related verification.
cmd/wasm/main.go Labels WASM trust checks as "mozilla".
README.md Updates example text to reflect the new default trust store.
CHANGELOG.md Documents breaking default change and performance improvements under Unreleased.
web/package-lock.json Updates various web dev dependencies/lockfile entries.
Files not reviewed (1)
  • web/package-lock.json: Language not supported

Comment thread bundle.go
Comment thread internal/certstore/memstore.go
Comment thread cmd/certkit/scan.go
Comment thread web/package-lock.json

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 690400c1f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bundle.go
Comment thread internal/certstore/memstore.go
Comment thread bundle.go
- Add semaphore (runtime.NumCPU) for system trust goroutines to avoid
  overwhelming macOS SecTrust with unbounded concurrent syscalls
- Guard VerifyChainTrust against nil cert before debug logging
- Retry bundle export with system trust store when mozilla fails, so
  certs trusted only by the host OS (corporate roots) export without
  requiring --force
- Fix golangci-lint modernize: use atomic.Int32 in verify_test.go

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 483bb63003

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/exporter.go Outdated
Comment on lines 305 to 309
if retryErr := certstore.ExportMatchedBundles(ctx, exportInput); retryErr == nil {
continue
}
}
if input.Opts.Verify && isBundleVerificationError(err) {

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 Preserve retry errors from system-trust fallback

When the initial Mozilla export fails verification and the system-store retry hits a different error, retryErr is discarded and execution falls through to the original err, which is still classified as a verification failure. In that case scan --bundle-path silently skips a system-trusted bundle as “untrusted” instead of surfacing the real failure (for example, if loading the system pool or writing the bundle files fails after the retry gets past verification).

Useful? React with 👍 / 👎.

Copilot AI review requested due to automatic review settings March 23, 2026 23:38
@danielewood
danielewood merged commit 0dbb950 into main Mar 23, 2026
18 checks passed
@danielewood
danielewood deleted the perf/trust-verification-parallelism branch March 23, 2026 23:43

Copilot AI left a comment

Copy link
Copy Markdown

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 11 out of 12 changed files in this pull request and generated 2 comments.

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

Comment thread internal/exporter.go
Comment on lines +308 to +342
func exportMatchedBundleWithSystemFallback(
ctx context.Context,
commonName string,
exportInput certstore.ExportMatchedBundleInput,
opts certkit.BundleOptions,
exportFn func(context.Context, certstore.ExportMatchedBundleInput) error,
) (bool, error) {
err := exportFn(ctx, exportInput)
if err == nil {
return false, nil
}

// If mozilla verification failed, retry with system trust store so
// certificates trusted only by the host OS (e.g. corporate keychain
// roots) still export without requiring --force.
if opts.Verify && isBundleVerificationError(err) && opts.TrustStore != "system" {
slog.Debug("mozilla trust failed, retrying with system trust store", "cn", commonName)
systemOpts := opts
systemOpts.TrustStore = "system"
exportInput.BundleOpts = systemOpts
retryErr := exportFn(ctx, exportInput)
if retryErr == nil {
return false, nil
}
if !isBundleVerificationError(retryErr) {
return false, fmt.Errorf("exporting bundle for %q: %w", commonName, retryErr)
}
}

if opts.Verify && isBundleVerificationError(err) {
slog.Debug("skipping untrusted bundle candidate", "cn", commonName, "error", err)
return true, nil
}

return false, fmt.Errorf("exporting bundle for %q: %w", commonName, err)

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

This helper takes 4 non-context parameters (commonName, exportInput, opts, exportFn), which violates CS-5 and makes the call sites harder to keep consistent (exportInput.BundleOpts vs opts can diverge). Consider introducing a small input struct (ctx stays separate) and/or deriving opts from exportInput.BundleOpts so there is a single source of truth.

Suggested change
func exportMatchedBundleWithSystemFallback(
ctx context.Context,
commonName string,
exportInput certstore.ExportMatchedBundleInput,
opts certkit.BundleOptions,
exportFn func(context.Context, certstore.ExportMatchedBundleInput) error,
) (bool, error) {
err := exportFn(ctx, exportInput)
if err == nil {
return false, nil
}
// If mozilla verification failed, retry with system trust store so
// certificates trusted only by the host OS (e.g. corporate keychain
// roots) still export without requiring --force.
if opts.Verify && isBundleVerificationError(err) && opts.TrustStore != "system" {
slog.Debug("mozilla trust failed, retrying with system trust store", "cn", commonName)
systemOpts := opts
systemOpts.TrustStore = "system"
exportInput.BundleOpts = systemOpts
retryErr := exportFn(ctx, exportInput)
if retryErr == nil {
return false, nil
}
if !isBundleVerificationError(retryErr) {
return false, fmt.Errorf("exporting bundle for %q: %w", commonName, retryErr)
}
}
if opts.Verify && isBundleVerificationError(err) {
slog.Debug("skipping untrusted bundle candidate", "cn", commonName, "error", err)
return true, nil
}
return false, fmt.Errorf("exporting bundle for %q: %w", commonName, err)
type exportMatchedBundleWithSystemFallbackInput struct {
CommonName string
ExportInput certstore.ExportMatchedBundleInput
ExportFn func(context.Context, certstore.ExportMatchedBundleInput) error
}
func exportMatchedBundleWithSystemFallback(
ctx context.Context,
input exportMatchedBundleWithSystemFallbackInput,
) (bool, error) {
err := input.ExportFn(ctx, input.ExportInput)
if err == nil {
return false, nil
}
opts := input.ExportInput.BundleOpts
// If mozilla verification failed, retry with system trust store so
// certificates trusted only by the host OS (e.g. corporate keychain
// roots) still export without requiring --force.
if opts.Verify && isBundleVerificationError(err) && opts.TrustStore != "system" {
slog.Debug("mozilla trust failed, retrying with system trust store", "cn", input.CommonName)
systemOpts := opts
systemOpts.TrustStore = "system"
input.ExportInput.BundleOpts = systemOpts
retryErr := input.ExportFn(ctx, input.ExportInput)
if retryErr == nil {
return false, nil
}
if !isBundleVerificationError(retryErr) {
return false, fmt.Errorf("exporting bundle for %q: %w", input.CommonName, retryErr)
}
}
if opts.Verify && isBundleVerificationError(err) {
slog.Debug("skipping untrusted bundle candidate", "cn", input.CommonName, "error", err)
return true, nil
}
return false, fmt.Errorf("exporting bundle for %q: %w", input.CommonName, err)

Copilot uses AI. Check for mistakes.
Comment thread internal/exporter.go
Comment on lines +320 to +327
// If mozilla verification failed, retry with system trust store so
// certificates trusted only by the host OS (e.g. corporate keychain
// roots) still export without requiring --force.
if opts.Verify && isBundleVerificationError(err) && opts.TrustStore != "system" {
slog.Debug("mozilla trust failed, retrying with system trust store", "cn", commonName)
systemOpts := opts
systemOpts.TrustStore = "system"
exportInput.BundleOpts = systemOpts

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

The comment says this retry is for when mozilla verification failed, but the condition only checks opts.TrustStore != "system". If this helper is ever called with an explicit non-mozilla trust store (e.g., "custom" in future), it would silently fall back to system roots and potentially export bundles that the caller did not intend to trust. Consider gating the retry on opts.TrustStore == "mozilla" (or otherwise making the intended behavior explicit).

Copilot uses AI. Check for mistakes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7aee93f7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +418 to +426
wg.Add(1)
go func(idx int, cert *x509.Certificate) {
defer wg.Done()
trustResults[idx].mozilla = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
Cert: cert,
Roots: input.MozillaPool,
Intermediates: intermediatePool,
TrustStore: "mozilla",
})

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 Bound Mozilla verification fan-out in scan summary

In ScanSummary this new Mozilla phase starts one goroutine per non-expired certificate with no semaphore, unlike the bounded system phase immediately below. On the large stores this change is targeting (for example concatenated PEM dumps with tens of thousands of certs), that means tens of thousands of live goroutines before any verification completes, which can add hundreds of MB of stack/scheduler overhead and turn the “speedup” into an OOM/GC-thrash regression. The same unbounded pattern also appears in cmd/certkit/scan.go's --dump-certs precompute path.

Useful? React with 👍 / 👎.

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