Skip to content

Commit 0dbb950

Browse files
danielewoodclaude
andauthored
perf: parallelize trust verification and default to mozilla root store (#183)
* perf: parallelize trust verification and default to mozilla root store 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> * perf: bound system trust concurrency, fix nil panic, lint fixes - 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> * fix: preserve export system trust retry errors --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 94e9a36 commit 0dbb950

12 files changed

Lines changed: 454 additions & 210 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Changed
2222

23+
- **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
24+
- 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
25+
- Add `TrustStore` label to `VerifyChainTrustInput` and debug-log every trust verification call with subject, store, and result
2326
- Normalize all exported private key PEM output (`.key`, K8s `tls.key`, YAML `key`) to PKCS#8 (`PRIVATE KEY`) regardless of input format ([#167])
2427
- Bundle export warns when Kubernetes TLS secret contains an unencrypted private key alongside encrypted outputs ([#167])
2528
- Use browser Web Crypto API for PBKDF2 key derivation in WASM builds to avoid blocking the main thread during encrypted key export ([#167])

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,9 @@ if certkit.CertExpiresWithin(cert, 30*24*time.Hour) {
403403
// cert expires within 30 days
404404
}
405405
406-
// Build verified chains (library defaults to system trust store)
406+
// Build verified chains (library defaults to mozilla trust store)
407407
opts := certkit.DefaultOptions()
408-
opts.TrustStore = "mozilla" // override the default system trust store if needed
408+
opts.TrustStore = "system" // override the default mozilla trust store if needed
409409
bundle, _ := certkit.Bundle(ctx, certkit.BundleInput{Leaf: leaf, Options: opts})
410410
411411
// Generate keys

bundle.go

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net"
1414
"net/http"
1515
"net/url"
16+
"runtime"
1617
"slices"
1718
"strings"
1819
"sync"
@@ -337,6 +338,8 @@ type VerifyChainTrustInput struct {
337338
Cert *x509.Certificate
338339
Roots *x509.CertPool
339340
Intermediates *x509.CertPool
341+
// TrustStore is an optional label for debug logging (e.g. "mozilla", "system").
342+
TrustStore string
340343
}
341344

342345
// CheckTrustAnchorsInput holds parameters for CheckTrustAnchors.
@@ -366,8 +369,18 @@ type CheckTrustAnchorsResult struct {
366369
// invalid at the leaf's issuance time. This is an uncommon edge case in
367370
// practice (intermediates outlive the leaves they sign).
368371
func VerifyChainTrust(input VerifyChainTrustInput) bool {
372+
if input.Cert == nil {
373+
return false
374+
}
375+
store := input.TrustStore
376+
if store == "" {
377+
store = "unknown"
378+
}
379+
slog.Debug("verifying chain trust", "subject", input.Cert.Subject.CommonName, "store", store)
369380
chains, err := verifyChainTrustChains(input)
370-
return err == nil && len(chains) > 0
381+
trusted := err == nil && len(chains) > 0
382+
slog.Debug("chain trust result", "subject", input.Cert.Subject.CommonName, "store", store, "trusted", trusted)
383+
return trusted
371384
}
372385

373386
func verifyChainTrustChains(input VerifyChainTrustInput) ([][]*x509.Certificate, error) {
@@ -413,6 +426,7 @@ func CheckTrustAnchors(input CheckTrustAnchorsInput) CheckTrustAnchorsResult {
413426
Cert: input.Cert,
414427
Roots: mozillaPool,
415428
Intermediates: input.Intermediates,
429+
TrustStore: "mozilla",
416430
}) {
417431
result.Anchors = append(result.Anchors, "mozilla")
418432
}
@@ -422,13 +436,15 @@ func CheckTrustAnchors(input CheckTrustAnchorsInput) CheckTrustAnchorsResult {
422436
Cert: input.Cert,
423437
Roots: systemPool,
424438
Intermediates: input.Intermediates,
439+
TrustStore: "system",
425440
}) {
426441
result.Anchors = append(result.Anchors, "system")
427442
}
428443
if input.FileRoots != nil && VerifyChainTrust(VerifyChainTrustInput{
429444
Cert: input.Cert,
430445
Roots: input.FileRoots,
431446
Intermediates: input.Intermediates,
447+
TrustStore: "file",
432448
}) {
433449
result.Anchors = append(result.Anchors, "file")
434450
}
@@ -496,7 +512,7 @@ func DefaultOptions() BundleOptions {
496512
AIATimeout: 2 * time.Second,
497513
AIAMaxDepth: 5,
498514
AIAMaxTotalCerts: defaultAIAMaxTotalCerts,
499-
TrustStore: "system",
515+
TrustStore: "mozilla",
500516
Verify: true,
501517
MaxIntermediates: defaultBundleMaxIntermediates,
502518
}
@@ -705,25 +721,64 @@ func countAIAUnresolvedIssuers(certs []*x509.Certificate, roots *x509.CertPool)
705721
}
706722
}
707723

708-
unresolved := 0
709-
for _, cert := range certs {
724+
// Identify candidates that need trust verification.
725+
type candidate struct {
726+
idx int
727+
cert *x509.Certificate
728+
}
729+
var candidates []candidate
730+
skipFlags := make([]bool, len(certs))
731+
for i, cert := range certs {
710732
if cert == nil {
733+
skipFlags[i] = true
711734
continue
712735
}
713736
if len(cert.IssuingCertificateURL) == 0 {
737+
skipFlags[i] = true
714738
continue
715739
}
716740
if IsMozillaRoot(cert) {
741+
skipFlags[i] = true
717742
continue
718743
}
719744
if bytes.Equal(cert.RawSubject, cert.RawIssuer) {
745+
skipFlags[i] = true
746+
continue
747+
}
748+
if roots != nil {
749+
candidates = append(candidates, candidate{idx: i, cert: cert})
750+
}
751+
}
752+
753+
// Verify trust for all candidates concurrently. Bounded to NumCPU
754+
// because system trust checks on macOS block in SecTrustEvaluateWithError.
755+
trusted := make([]bool, len(certs))
756+
if len(candidates) > 0 {
757+
var wg sync.WaitGroup
758+
sem := make(chan struct{}, runtime.NumCPU())
759+
for _, c := range candidates {
760+
wg.Add(1)
761+
sem <- struct{}{}
762+
go func(idx int, cert *x509.Certificate) {
763+
defer wg.Done()
764+
defer func() { <-sem }()
765+
trusted[idx] = VerifyChainTrust(VerifyChainTrustInput{
766+
Cert: cert,
767+
Roots: roots,
768+
Intermediates: intermediates,
769+
TrustStore: "aia-resolve",
770+
})
771+
}(c.idx, c.cert)
772+
}
773+
wg.Wait()
774+
}
775+
776+
unresolved := 0
777+
for i, cert := range certs {
778+
if skipFlags[i] {
720779
continue
721780
}
722-
if roots != nil && VerifyChainTrust(VerifyChainTrustInput{
723-
Cert: cert,
724-
Roots: roots,
725-
Intermediates: intermediates,
726-
}) {
781+
if trusted[i] {
727782
continue
728783
}
729784
if hasIssuerInSet(cert, certs) {

bundle_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ func TestDefaultOptions(t *testing.T) {
159159
if opts.AIAMaxTotalCerts != defaultAIAMaxTotalCerts {
160160
t.Fatalf("AIAMaxTotalCerts = %d, want %d", opts.AIAMaxTotalCerts, defaultAIAMaxTotalCerts)
161161
}
162-
if opts.TrustStore != "system" {
163-
t.Fatalf("TrustStore = %q, want system", opts.TrustStore)
162+
if opts.TrustStore != "mozilla" {
163+
t.Fatalf("TrustStore = %q, want mozilla", opts.TrustStore)
164164
}
165165
if !opts.Verify {
166166
t.Fatal("Verify = false, want true")

cmd/certkit/scan.go

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import (
1212
"log/slog"
1313
"net/http"
1414
"os"
15+
"runtime"
1516
"strconv"
1617
"strings"
18+
"sync"
1719
"time"
1820

1921
"github.com/sensiblebit/certkit"
@@ -279,10 +281,61 @@ func runScan(cmd *cobra.Command, args []string) error {
279281
}
280282
intermediatePool := store.IntermediatePool()
281283

284+
// Pre-compute trust status concurrently. Mozilla checks are pure Go
285+
// and fast; system checks hit macOS SecTrust which is slow. Run
286+
// mozilla first, then only check system for untrusted remainders.
287+
type dumpTrust struct {
288+
mozilla bool
289+
system bool
290+
}
291+
now := time.Now()
292+
trustStatus := make([]dumpTrust, len(certs))
293+
if !scanForceExport && (trustPools.Mozilla != nil || trustPools.System != nil) {
294+
var wg sync.WaitGroup
295+
if trustPools.Mozilla != nil {
296+
for i, c := range certs {
297+
if !allowExpired && now.After(c.Cert.NotAfter) {
298+
continue
299+
}
300+
wg.Add(1)
301+
go func(idx int, cert *x509.Certificate) {
302+
defer wg.Done()
303+
trustStatus[idx].mozilla = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
304+
Cert: cert,
305+
Roots: trustPools.Mozilla,
306+
Intermediates: intermediatePool,
307+
TrustStore: "mozilla",
308+
})
309+
}(i, c.Cert)
310+
}
311+
wg.Wait()
312+
}
313+
if trustPools.System != nil {
314+
sem := make(chan struct{}, runtime.NumCPU())
315+
for i, c := range certs {
316+
if (!allowExpired && now.After(c.Cert.NotAfter)) || trustStatus[i].mozilla {
317+
continue
318+
}
319+
wg.Add(1)
320+
sem <- struct{}{}
321+
go func(idx int, cert *x509.Certificate) {
322+
defer wg.Done()
323+
defer func() { <-sem }()
324+
trustStatus[idx].system = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
325+
Cert: cert,
326+
Roots: trustPools.System,
327+
Intermediates: intermediatePool,
328+
TrustStore: "system",
329+
})
330+
}(i, c.Cert)
331+
}
332+
wg.Wait()
333+
}
334+
}
335+
282336
var data []byte
283337
var count, skipped int
284-
now := time.Now()
285-
for _, c := range certs {
338+
for i, c := range certs {
286339
cert := c.Cert
287340

288341
// Skip expired certificates unless --allow-expired is set
@@ -294,17 +347,7 @@ func runScan(cmd *cobra.Command, args []string) error {
294347

295348
// Validate chain unless --force is set (uses same logic as summary)
296349
if !scanForceExport {
297-
mozillaTrusted := trustPools.Mozilla != nil && certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
298-
Cert: cert,
299-
Roots: trustPools.Mozilla,
300-
Intermediates: intermediatePool,
301-
})
302-
systemTrusted := trustPools.System != nil && certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
303-
Cert: cert,
304-
Roots: trustPools.System,
305-
Intermediates: intermediatePool,
306-
})
307-
if !mozillaTrusted && !systemTrusted && (trustPools.Mozilla != nil || trustPools.System != nil) {
350+
if !trustStatus[i].mozilla && !trustStatus[i].system && (trustPools.Mozilla != nil || trustPools.System != nil) {
308351
slog.Debug("skipping untrusted certificate", "subject", cert.Subject)
309352
skipped++
310353
continue

cmd/wasm/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ func getState(_ js.Value, _ []js.Value) any {
327327
expired := now.After(rec.NotAfter)
328328
trusted := false
329329
if roots != nil {
330-
trusted = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{Cert: rec.Cert, Roots: roots, Intermediates: intermediatePool})
330+
trusted = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{Cert: rec.Cert, Roots: roots, Intermediates: intermediatePool, TrustStore: "mozilla"})
331331
}
332332

333333
serial := ""

internal/certstore/memstore.go

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"log/slog"
1313
"maps"
14+
"runtime"
1415
"slices"
1516
"sort"
1617
"sync"
@@ -398,19 +399,61 @@ func (s *MemStore) ScanSummary(input ScanSummaryInput) ScanSummary {
398399
}
399400
}
400401

402+
// Pre-compute trust status for all non-expired certs concurrently.
403+
// Mozilla checks are pure Go and fast; system checks hit the macOS
404+
// Security framework (SecTrustEvaluateWithError) which is slow.
405+
// Run mozilla first, then only check system for certs mozilla didn't trust.
406+
type trustStatus struct {
407+
mozilla bool
408+
system bool
409+
}
401410
now := time.Now()
402-
for _, rec := range certs {
403-
expired := now.After(rec.NotAfter)
404-
mozillaTrusted := false
405-
systemTrusted := false
406-
if !expired {
407-
if input.MozillaPool != nil {
408-
mozillaTrusted = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{Cert: rec.Cert, Roots: input.MozillaPool, Intermediates: intermediatePool})
411+
trustResults := make([]trustStatus, len(certs))
412+
var wg sync.WaitGroup
413+
if input.MozillaPool != nil {
414+
for i, rec := range certs {
415+
if now.After(rec.NotAfter) {
416+
continue
409417
}
410-
if input.SystemPool != nil {
411-
systemTrusted = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{Cert: rec.Cert, Roots: input.SystemPool, Intermediates: intermediatePool})
418+
wg.Add(1)
419+
go func(idx int, cert *x509.Certificate) {
420+
defer wg.Done()
421+
trustResults[idx].mozilla = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
422+
Cert: cert,
423+
Roots: input.MozillaPool,
424+
Intermediates: intermediatePool,
425+
TrustStore: "mozilla",
426+
})
427+
}(i, rec.Cert)
428+
}
429+
wg.Wait()
430+
}
431+
if input.SystemPool != nil {
432+
sem := make(chan struct{}, runtime.NumCPU())
433+
for i, rec := range certs {
434+
if now.After(rec.NotAfter) || trustResults[i].mozilla {
435+
continue
412436
}
437+
wg.Add(1)
438+
sem <- struct{}{}
439+
go func(idx int, cert *x509.Certificate) {
440+
defer wg.Done()
441+
defer func() { <-sem }()
442+
trustResults[idx].system = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{
443+
Cert: cert,
444+
Roots: input.SystemPool,
445+
Intermediates: intermediatePool,
446+
TrustStore: "system",
447+
})
448+
}(i, rec.Cert)
413449
}
450+
wg.Wait()
451+
}
452+
453+
for i, rec := range certs {
454+
expired := now.After(rec.NotAfter)
455+
mozillaTrusted := trustResults[i].mozilla
456+
systemTrusted := trustResults[i].system
414457

415458
switch rec.CertType {
416459
case "root":

0 commit comments

Comments
 (0)