fix(crypto/local-ca): reject expired or not-yet-valid sub-CA certificates on disk load (M-5)

loadCAFromDisk now validates the upstream sub-CA certificate's NotBefore
and NotAfter fields before accepting it, returning a fail-closed error
at server startup instead of silently loading an out-of-window CA.

Before this fix, loadCAFromDisk checked BasicConstraints.IsCA and
KeyUsage=CertSign but not the validity window. An expired enterprise
sub-CA (e.g. an ADCS subordinate whose rollover slipped) would load
without warning and the scheduler would mint child certs that every
RFC 5280 path validator rejects — outages show up at relying parties,
not at certctl, and only after thresholds trip.

CWE-672 (Operation on a Resource after Expiration or Release); secondary
CWE-295 (Improper Certificate Validation). Error strings include the CA
subject CommonName and both RFC3339 timestamps so the log line is
actionable in a 3am incident.

Tests: TestSubCAMode gains three subtests exercising the new gate —
SubCA_ExpiredCert_IsRejected (CA expired 1h ago → error mentions
'expired' and the CN), SubCA_NotYetValid_IsRejected (CA valid +1h →
error mentions 'not yet valid' and the CN), and SubCA_BarelyValid_IsAccepted
(CA valid [now-1m, now+1h] → issuance succeeds, proving no
over-rejection). Adds generateTestSubCAWithValidity helper; the
original generateTestSubCA wrapper preserves the [now, now+5y] default
for existing tests.

Package coverage: 67.7% -> 68.3%.

Verification: go build, go vet, go test -race, go test -cover all
green locally; golangci-lint v2.11.4 clean; govulncheck clean. All CI
coverage floors met with margin (service 67.6/55, handler 78.6/60,
domain 92.7/40, middleware 80.0/30, crypto 86.7/85).

Parent: 76d383b (M-8 per-ciphertext salt).
Closes: audit finding M-5 in certctl-audit-report.md.
This commit is contained in:
Shankar
2026-04-17 14:10:23 +00:00
parent 76d383bd64
commit 2cdf17dddd
2 changed files with 139 additions and 3 deletions
+19
View File
@@ -359,6 +359,25 @@ func (c *Connector) loadCAFromDisk() error {
return fmt.Errorf("loaded CA certificate does not have KeyUsageCertSign")
}
// Validate CA certificate validity window (M-5, CWE-672).
// An expired or not-yet-valid sub-CA produces child certificates that any
// RFC 5280 path-validator will reject. Fail closed at load time so operators
// learn about it at startup, not at 3am when a renewal cycle silently
// starts minting broken certs. See audit finding M-5.
now := time.Now()
if now.After(caCert.NotAfter) {
return fmt.Errorf("CA certificate %q has expired (not_after=%s, now=%s)",
caCert.Subject.CommonName,
caCert.NotAfter.UTC().Format(time.RFC3339),
now.UTC().Format(time.RFC3339))
}
if now.Before(caCert.NotBefore) {
return fmt.Errorf("CA certificate %q is not yet valid (not_before=%s, now=%s)",
caCert.Subject.CommonName,
caCert.NotBefore.UTC().Format(time.RFC3339),
now.UTC().Format(time.RFC3339))
}
// Load CA private key (supports RSA and ECDSA)
keyPEM, err := os.ReadFile(c.config.CAKeyPath)
if err != nil {