Skip to content

Commit 6ddcfe4

Browse files
authored
Merge pull request #171 from sensiblebit/feat/trust-anchor-source-tracking
feat: track trust anchor sources
2 parents 02038b1 + ea915a5 commit 6ddcfe4

20 files changed

+883
-228
lines changed

.claude/docs/architecture.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ Stateless utility functions. No database, no file I/O. This is the public librar
77
- `certkit.go` — PEM parsing, key generation, fingerprints, SKI computation. `DeduplicatePasswords()`, `ParseCertificatesAny()` (DER/PEM/PKCS#7). `MarshalEncryptedPrivateKeyToPEM()` encrypts a private key to PKCS#8 v2 PEM (PBES2/AES-256-CBC). `decryptPKCS8PrivateKey()` (unexported) decrypts PKCS#8 v2 encrypted keys; wired into `ParsePEMPrivateKeyWithPasswords()`. PBKDF2 key derivation is delegated to platform-specific `derivePBKDF2Key()`.
88
- `pbkdf2.go` — Native PBKDF2-HMAC-SHA-256 implementation (`//go:build !js`) using Go stdlib `crypto/pbkdf2`.
99
- `pbkdf2_js.go` — WASM PBKDF2 implementation (`//go:build js`) using the browser's Web Crypto `SubtleCrypto.deriveBits()` API. Runs key derivation off the main thread so CSS animations continue during export.
10-
- `bundle.go` — Certificate chain resolution via AIA, trust store verification. `BundleResult`/`BundleOptions` types, `DefaultOptions()`, `FetchLeafFromURL()`, `FetchAIACertificates()`, `Bundle()`. `MozillaRootPool()` (`sync.Once`-cached), `MozillaRootPEM()`.
11-
- `connect.go` — Transport connection probing and chain diagnostics. `ConnectTLS()` handles implicit TLS plus opportunistic mail-protocol STARTTLS/STLS upgrades for SMTP, IMAP, and POP3, plus LDAP `StartTLS` on port `389`; surfaces useful non-TLS diagnostics for SSH/HTTP/plaintext services; and returns negotiated protocol, cipher suite, peer chain, mTLS info, and verification result with automatic AIA walking for missing intermediates. `ScanCipherSuites()` enumerates supported TLS suites and key exchange groups, including STARTTLS-aware scans and optional QUIC probing. `DiagnoseConnectChain()` detects root-in-chain (RFC 8446 §4.4.2), duplicate certs, and missing intermediates. `FormatConnectResult()` renders the shared text summary, while the CLI verbose formatter appends a PEM copy of the server-sent chain with metadata headers. Types: `ConnectTLSInput`, `ConnectResult`, `ClientAuthInfo`, `ChainDiagnostic`, `ScanCipherSuitesInput`, `CipherScanResult`.
10+
- `bundle.go` — Certificate chain resolution via AIA, trust store verification, and cross-store trust probing. `BundleResult`/`BundleOptions` types, `DefaultOptions()`, `FetchLeafFromURL()`, `FetchAIACertificates()`, `Bundle()`, `CheckTrustAnchors()`, `FormatTrustAnchors()`. `MozillaRootPool()` and `SystemCertPoolCached()` are `sync.Once`-cached; `MozillaRootPEM()` exposes the embedded root bundle.
11+
- `connect.go` — Transport connection probing and chain diagnostics. `ConnectTLS()` handles implicit TLS plus opportunistic mail-protocol STARTTLS/STLS upgrades for SMTP, IMAP, and POP3, plus LDAP `StartTLS` on port `389`; surfaces useful non-TLS diagnostics for SSH/HTTP/plaintext services; and returns negotiated protocol, cipher suite, peer chain, mTLS info, and verification result with automatic AIA walking for missing intermediates. `ScanCipherSuites()` enumerates supported TLS suites and key exchange groups, including STARTTLS-aware scans and optional QUIC probing. `DiagnoseConnectChain()` detects root-in-chain (RFC 8446 §4.4.2), duplicate certs, and misordered chains; `ConnectTLS()` appends the `missing-intermediate` diagnostic during AIA recovery when applicable. `FormatConnectResult()` renders the shared text summary, while the CLI verbose formatter appends a PEM copy of the server-sent chain with metadata headers. Types: `ConnectTLSInput`, `ConnectResult`, `ClientAuthInfo`, `ChainDiagnostic`, `ScanCipherSuitesInput`, `CipherScanResult`.
1212
- `connect_policy.go` — Conservative policy heuristics for negotiated and scanned TLS results. Flags protocol versions, cipher suites, and leaf certificate key/signature algorithms that are likely not authorized by the selected policy profile.
1313
- `security_policy.go` — Shared policy type definitions. `SecurityPolicy` currently exposes `fips-140-2` and `fips-140-3` heuristic modes used by both TLS and SSH probing.
1414
- `probe_tls13.go` — Byte-level TLS 1.3 ClientHello construction and response parsing used by `ScanCipherSuites()` for TLS 1.3 cipher and key-exchange-group probing.
@@ -31,7 +31,7 @@ Certificate/key processing, in-memory storage, and persistence. Used by both CLI
3131
- `certstore.go``CertHandler` interface (`HandleCertificate`, `HandleKey`), `ProcessInput` struct.
3232
- `process.go``ProcessData()`: format detection and parsing pipeline (PEM → DER → PKCS#7PKCS#8 → SEC1 → Ed25519 → JKS → PKCS#12). Calls `CertHandler` for each parsed item.
3333
- `memstore.go``MemStore`: in-memory `CertHandler` implementation and primary runtime store. `CertRecord`/`KeyRecord` types. Stores multiple certs per SKI via composite key (serial + AKI). Provides `ScanSummary()`, `AllCertsFlat()`, `AllKeysFlat()`, `CertsByBundleName()`, `BundleNames()`, `DumpDebug()`.
34-
- `summary.go``ScanSummary` struct (roots, intermediates, leaves, keys, matched pairs).
34+
- `summary.go``ScanSummary` struct for aggregate scan counts, including roots/intermediates/leaves/keys/matches plus expired, Mozilla-trusted, system-trusted, and untrusted certificate totals.
3535
- `export.go``GenerateBundleFiles()`: creates all output files for a bundle (PEM variants, key, P12, K8s YAML, JSON, YAML, CSR). All key output is normalized to PKCS#8 format. `BundleExportInput` and `ExportMatchedBundleInput` support an `EncryptKey` option for PKCS#8 v2 password-protecting exported `.key` files. `GenerateJSON`, `GenerateYAML`, `GenerateCSR` also exported individually. `BundleWriter` interface and `ExportMatchedBundles()` provide shared export orchestration for both CLI and WASM.
3636
- `validate.go` — Certificate validation checks. `RunValidation()` orchestrates all checks for a certificate. `CheckExpiration()`, `CheckKeyStrength()`, `CheckSignature()`, `CheckTrustChain()` for individual validation steps. Types: `RunValidationInput`, `ValidationResult`, `ValidationCheck`, `CheckTrustChainInput`.
3737
- `aia.go` — Store-aware AIA resolution. `ResolveAIA()` fetches missing intermediates via AIA URLs using an `AIAFetcher` callback. `HasUnresolvedIssuers()` checks if any certs need issuer resolution. Type: `ResolveAIAInput`.
@@ -66,7 +66,7 @@ Thin CLI layer. Each file is one Cobra command. Flag variables are package-level
6666
- `scan.go` — Main scanning command with `--dump-keys`, `--dump-certs`, `--max-file-size`, `--bundle-path` flags.
6767
- `bundle.go` — Build verified certificate chains from leaf certs; resolves intermediates via AIA; outputs PEM, chain, fullchain, PKCS#12, or JKS with `--key`, `--force`, `--trust-store` flags.
6868
- `inspect.go` — Display detailed certificate, key, or CSR information with text or JSON output (`--format`); filters expired items unless `--allow-expired`.
69-
- `verify.go` — Verify certificate chains, key matches, expiry windows, and optional OCSP/CRL status; returns exit code 2 on validation failures; `--key`, `--expiry`, `--trust-store`, `--diagnose`, `--ocsp`, `--crl`, `--format` flags.
69+
- `verify.go` — Verify certificate chains, key matches, expiry windows, and optional OCSP/CRL status; returns exit code 2 on validation failures; always checks Mozilla + system trust and accepts `--roots` for additional file-backed trust anchors. Flags: `--key`, `--roots`, `--expiry`, `--diagnose`, `--ocsp`, `--crl`, `--format`.
7070
- `connect.go` — Test TLS connections and display certificate chain details; supports implicit TLS plus STARTTLS/STLS upgrades, optional cipher enumeration, OCSP/CRL checks, and FIPS-style policy diagnostics. In verbose text mode it also appends the server-sent certificate chain in PEM with metadata headers for direct reuse. Flags: `--servername`, `--ciphers`, `--no-ocsp`, `--crl`, `--fips-140-2`, `--fips-140-3`, `--format`.
7171
- `probe.go` — Parent `probe` command for transport-oriented inspection commands.
7272
- `probe_ssh.go``probe ssh` subcommand. Connects without authenticating, prints banner/algorithm details, and supports `--fips-140-2` / `--fips-140-3` policy heuristics for SSH transport algorithms.

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Add `tree` subcommand to display the full CLI command, subcommand, and flag surface in a tree layout ([#169])
1313
- Encrypt PEM private key output (`.key`) using PKCS#8 v2 (AES-256-CBC) when an explicit export password is supplied ([#167])
1414
- Support decryption of PKCS#8 v2 encrypted private keys (`ENCRYPTED PRIVATE KEY` PEM blocks) with all PBES2 cipher (AES-128/192/256-CBC, 3DES-CBC) and PRF (HMAC-SHA-1/256/384/512) combinations ([#167])
15+
- Add `trust_anchors` reporting across `inspect`, `verify`, and `connect` JSON output so certificates show which trust sources validate them (`mozilla`/`system` everywhere, plus optional `file` roots in `verify`) ([`0ee41ad`])
16+
- Add per-store Mozilla/system trust counts to `scan` JSON summaries alongside the existing aggregate `untrusted_*` counts ([`0ee41ad`])
17+
- Add `verify --roots <file>` to include PEM/DER/PKCS#7/PKCS#12/JKS certificates as an additional file-backed trust source ([`0ee41ad`])
18+
- Add `CheckTrustAnchorsResult` so library callers can inspect `trust_anchors` plus trust-source load warnings from `CheckTrustAnchors` ([#171])
1519

1620
### Changed
1721

1822
- Normalize all exported private key PEM output (`.key`, K8s `tls.key`, YAML `key`) to PKCS#8 (`PRIVATE KEY`) regardless of input format ([#167])
1923
- Bundle export warns when Kubernetes TLS secret contains an unencrypted private key alongside encrypted outputs ([#167])
2024
- Use browser Web Crypto API for PBKDF2 key derivation in WASM builds to avoid blocking the main thread during encrypted key export ([#167])
25+
- `verify` now checks both Mozilla and system trust stores by default and treats a certificate as trusted when any available anchor source succeeds ([`0ee41ad`])
26+
- `scan` now counts `untrusted_*` certificates as trusted by neither Mozilla nor system, and exposes per-store trust counts in JSON output ([`0ee41ad`])
27+
- 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])
28+
29+
### Removed
30+
31+
- **Breaking:** Remove `verify --trust-store`; use the default Mozilla+system verification or `--roots` to add a file-backed trust source ([`0ee41ad`])
2132

2233
### Fixed
2334

@@ -1097,6 +1108,7 @@ Initial release.
10971108
[#158]: https://github.com/sensiblebit/certkit/pull/158
10981109
[#167]: https://github.com/sensiblebit/certkit/issues/167
10991110
[#169]: https://github.com/sensiblebit/certkit/pull/169
1111+
[#171]: https://github.com/sensiblebit/certkit/pull/171
11001112
[#73]: https://github.com/sensiblebit/certkit/pull/73
11011113
[#64]: https://github.com/sensiblebit/certkit/pull/64
11021114
[#63]: https://github.com/sensiblebit/certkit/pull/63
@@ -1115,3 +1127,4 @@ Initial release.
11151127
[#27]: https://github.com/sensiblebit/certkit/pull/27
11161128
[`6492fa5`]: https://github.com/sensiblebit/certkit/commit/6492fa5
11171129
[`772742c`]: https://github.com/sensiblebit/certkit/commit/772742c
1130+
[`0ee41ad`]: https://github.com/sensiblebit/certkit/commit/0ee41ad

EXAMPLES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,10 @@ Chain verification happens automatically -- certkit always checks that a cert ch
151151
certkit verify cert.pem
152152
```
153153

154-
By default this checks against the Mozilla root store (embedded, works everywhere). To check against your OS trust store instead:
154+
By default this checks against both the embedded Mozilla roots and your OS trust store. To add a private root file as another trust source:
155155

156156
```sh
157-
certkit verify cert.pem --trust-store system
157+
certkit verify cert.pem --roots private-ca.pem
158158
```
159159

160160
Combine all checks at once:

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,19 +151,19 @@ Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always
151151
### Verify Flags
152152

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

166-
Chain verification is always performed. 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).
166+
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. 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).
167167

168168
### Connect Flags
169169

bundle.go

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"net/http"
1515
"net/url"
1616
"slices"
17+
"strings"
1718
"sync"
1819
"time"
1920

@@ -24,6 +25,9 @@ var (
2425
mozillaPoolOnce sync.Once
2526
mozillaPool *x509.CertPool
2627
errMozillaPool error
28+
systemPoolOnce sync.Once
29+
systemPool *x509.CertPool
30+
errSystemPool error
2731
mozillaSubjectsOnce sync.Once
2832
mozillaSubjects map[string]bool
2933
mozillaRootKeysOnce sync.Once
@@ -47,6 +51,8 @@ var (
4751
errBundleLeafNil = errors.New("leaf certificate is nil")
4852
errBundleMaxChainExceeded = errors.New("certificate chain exceeded maximum intermediate limit")
4953
errBundleUnknownTrustStore = errors.New("unknown trust_store")
54+
errVerifyChainCertNil = errors.New("certificate is nil")
55+
errVerifyChainRootsNil = errors.New("root pool is nil")
5056
)
5157

5258
// privateNetworks contains CIDR ranges for private, reserved, and shared
@@ -97,6 +103,19 @@ func MozillaRootPool() (*x509.CertPool, error) {
97103
return mozillaPool, errMozillaPool
98104
}
99105

106+
// SystemCertPoolCached returns a shared x509.CertPool containing the host
107+
// system roots. The pool is initialized once and cached for the lifetime of
108+
// the process.
109+
func SystemCertPoolCached() (*x509.CertPool, error) {
110+
systemPoolOnce.Do(func() {
111+
systemPool, errSystemPool = x509.SystemCertPool()
112+
if errSystemPool != nil {
113+
errSystemPool = fmt.Errorf("loading system cert pool: %w", errSystemPool)
114+
}
115+
})
116+
return systemPool, errSystemPool
117+
}
118+
100119
// MozillaRootSubjects returns a set of raw ASN.1 subject byte strings from all
101120
// Mozilla root certificates. The result is initialized once and cached for the
102121
// lifetime of the process.
@@ -293,6 +312,20 @@ type VerifyChainTrustInput struct {
293312
Intermediates *x509.CertPool
294313
}
295314

315+
// CheckTrustAnchorsInput holds parameters for CheckTrustAnchors.
316+
type CheckTrustAnchorsInput struct {
317+
Cert *x509.Certificate
318+
Intermediates *x509.CertPool
319+
FileRoots *x509.CertPool
320+
}
321+
322+
// CheckTrustAnchorsResult reports which trust sources validated a certificate
323+
// and any source-load warnings encountered while probing.
324+
type CheckTrustAnchorsResult struct {
325+
Anchors []string
326+
Warnings []string
327+
}
328+
296329
// VerifyChainTrust reports whether the given certificate chains to a trusted
297330
// root. Cross-signed roots (same Subject and public key as a Mozilla root)
298331
// are trusted directly. For expired certificates, verification is performed
@@ -306,11 +339,19 @@ type VerifyChainTrustInput struct {
306339
// invalid at the leaf's issuance time. This is an uncommon edge case in
307340
// practice (intermediates outlive the leaves they sign).
308341
func VerifyChainTrust(input VerifyChainTrustInput) bool {
342+
chains, err := verifyChainTrustChains(input)
343+
return err == nil && len(chains) > 0
344+
}
345+
346+
func verifyChainTrustChains(input VerifyChainTrustInput) ([][]*x509.Certificate, error) {
347+
if input.Cert == nil {
348+
return nil, errVerifyChainCertNil
349+
}
309350
if input.Roots == nil {
310-
return false
351+
return nil, errVerifyChainRootsNil
311352
}
312353
if IsMozillaRoot(input.Cert) {
313-
return true
354+
return [][]*x509.Certificate{{input.Cert}}, nil
314355
}
315356
opts := x509.VerifyOptions{
316357
Roots: input.Roots,
@@ -321,8 +362,58 @@ func VerifyChainTrust(input VerifyChainTrustInput) bool {
321362
// Use NotBefore + 1s: the issuing chain was necessarily valid at issuance.
322363
opts.CurrentTime = input.Cert.NotBefore.Add(time.Second)
323364
}
324-
_, err := input.Cert.Verify(opts)
325-
return err == nil
365+
chains, err := input.Cert.Verify(opts)
366+
if err != nil {
367+
return nil, fmt.Errorf("verifying certificate against roots: %w", err)
368+
}
369+
return chains, nil
370+
}
371+
372+
// CheckTrustAnchors reports which trust sources validate the certificate.
373+
// Results are returned in stable order: mozilla, system, file.
374+
func CheckTrustAnchors(input CheckTrustAnchorsInput) CheckTrustAnchorsResult {
375+
if input.Cert == nil {
376+
return CheckTrustAnchorsResult{Anchors: []string{}, Warnings: []string{}}
377+
}
378+
379+
result := CheckTrustAnchorsResult{
380+
Anchors: make([]string, 0, 3),
381+
Warnings: make([]string, 0, 2),
382+
}
383+
if mozillaPool, err := MozillaRootPool(); err != nil {
384+
result.Warnings = append(result.Warnings, fmt.Sprintf("mozilla trust source unavailable: %v", err))
385+
} else if VerifyChainTrust(VerifyChainTrustInput{
386+
Cert: input.Cert,
387+
Roots: mozillaPool,
388+
Intermediates: input.Intermediates,
389+
}) {
390+
result.Anchors = append(result.Anchors, "mozilla")
391+
}
392+
if systemPool, err := SystemCertPoolCached(); err != nil {
393+
result.Warnings = append(result.Warnings, fmt.Sprintf("system trust source unavailable: %v", err))
394+
} else if VerifyChainTrust(VerifyChainTrustInput{
395+
Cert: input.Cert,
396+
Roots: systemPool,
397+
Intermediates: input.Intermediates,
398+
}) {
399+
result.Anchors = append(result.Anchors, "system")
400+
}
401+
if input.FileRoots != nil && VerifyChainTrust(VerifyChainTrustInput{
402+
Cert: input.Cert,
403+
Roots: input.FileRoots,
404+
Intermediates: input.Intermediates,
405+
}) {
406+
result.Anchors = append(result.Anchors, "file")
407+
}
408+
return result
409+
}
410+
411+
// FormatTrustAnchors renders trust anchor labels for display.
412+
func FormatTrustAnchors(anchors []string) string {
413+
if len(anchors) == 0 {
414+
return "none"
415+
}
416+
return strings.Join(anchors, ", ")
326417
}
327418

328419
// BundleResult holds the resolved chain and metadata.

0 commit comments

Comments
 (0)