mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 04:28:54 +00:00
1dcc7455cd
Closes H-010 + L-002 + L-003 + L-012 + L-014 from
comprehensive-audit-2026-04-25; partial-closes M-028 (the local.go:682
elliptic.Marshal site only).
H-010 (CWE-1257) — local-issuer coverage 68.3% -> 86.7%
* internal/connector/issuer/local/bundle9_coverage_test.go (NEW)
Adds ~30 subtests across CSR-acceptance failure paths, parsePrivateKey
four-format coverage, resolveEKUsAndKeyUsage all-EKU + fallback,
hashPublicKey RSA + ECDSA P-256/P-384/P-521 + unsupported curve,
ecdsaToECDH byte-identical round-trip pin, loadCAFromDisk
expired/non-CA/missing/happy, validateCSRUnicode all rejection arms,
marshalPrivateKeyAndZeroize / ensureKeyDirSecure all branches,
ValidateConfig 5 arms, MaxTTLSeconds cap.
* .github/workflows/ci.yml — flips local-issuer floor 60% -> 85% hard
with explicit "add tests, do not lower the gate" comment.
L-002 (CWE-226) — agent + local-CA private-key zeroization
* internal/connector/issuer/local/keymem.go (NEW)
* cmd/agent/keymem.go (NEW)
marshalPrivateKeyAndZeroize wraps x509.MarshalECPrivateKey with
defer clear(der). Agent additionally defer clear(privKeyPEM) on the
encoded buffer. Bounds heap-resident exposure of the private scalar
to the duration of PEM-encode + os.WriteFile.
L-003 (CWE-732) — 0700 key-directory hardening
* internal/connector/issuer/local/keystore.go (NEW)
* cmd/agent/keymem.go (NEW)
ensureKeyDirSecure / ensureAgentKeyDirSecure create dir tree at 0700,
accept owner-only modes, chmod-tighten permissive leaves with
re-stat verification, refuse empty/root/dot. Wired ahead of every
os.WriteFile(keyPath, ..., 0600) site in cmd/agent/main.go.
L-012 (CWE-1007 + CWE-176) — Unicode safety in CN/SAN
* internal/validation/unicode.go (NEW)
* internal/validation/unicode_test.go (NEW, 8 test functions)
ValidateUnicodeSafe rejects RTL/LTR overrides U+202A..U+202E +
U+2066..U+2069, zero-width U+200B..U+200D + U+2060 + U+FEFF,
control chars <0x20 + 0x7F..0x9F, and per-DNS-label
Latin+non-Latin-letter mixes (Cyrillic-а-in-apple homograph).
Pure-IDN labels allowed. Errors cite codepoint + byte offset.
Wired into IssueCertificate + RenewCertificate via
validateCSRUnicode covering CSR Subject CommonName + DNSNames +
EmailAddresses + request-side additional SANs.
L-014 — CA-key-in-process threat-model documentation
* internal/connector/issuer/local/local.go file-header doc comment
Documents what the bundled defense-in-depth measures DO and DO NOT
protect against; directs operators with stricter requirements to
HSM/PKCS#11/cloud-KMS-backed signing (V3 Pro KMS-issuance roadmap
entry as the source-of-truth fix).
M-028 (CWE-477) PARTIAL — 1 of 6 SA1019 sites
* internal/connector/issuer/local/local.go::ecdsaToECDH (NEW helper)
Replaces deprecated elliptic.Marshal(k.Curve, k.X, k.Y) inside
hashPublicKey with crypto/ecdh.PublicKey.Bytes(). Dispatches on
Curve.Params().Name to avoid importing crypto/elliptic for sentinel
comparisons. Supports P-256/P-384/P-521; P-224 returns
unsupported-curve error and the caller falls back to a stable X+Y
big.Int.Bytes() hash (so SKI generation never panics).
* TestHashPublicKey_ECDSA_RoundTripPin — byte-identical regression
oracle that pins the new output to the legacy elliptic.Marshal
output across all three supported curves (with explicit
//nolint:staticcheck on the SA1019 reference). Migration cannot
silently change the SubjectKeyId of every previously-issued cert.
* 5 SA1019 sites still open (test-file middleware.NewAuth × 3 +
scep.go csr.Attributes).
Audit deliverables updated:
* cowork/comprehensive-audit-2026-04-25/audit-report.md — score
20/55 -> 25/55 closed (High 6/9 -> 7/9; Low 4/19 -> 8/19).
* cowork/comprehensive-audit-2026-04-25/findings.yaml — H-010 +
L-002 + L-003 + L-012 + L-014 status open -> closed; M-028 status
open -> partial_closed; closure notes cite the Bundle-9 mechanism.
* certctl/CHANGELOG.md — Bundle-9 section under [unreleased].
90 lines
3.3 KiB
Go
90 lines
3.3 KiB
Go
package local
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Bundle-9 / Audit L-003 (Key directory parents inherit umask, not 0700):
|
|
//
|
|
// When the local CA writes a key file with mode 0600 to /var/lib/certctl/ca.key,
|
|
// the FILE is unreadable by other users — but if /var/lib/certctl was created
|
|
// with the process umask (typically 0022, yielding 0755), then any local user
|
|
// can `ls /var/lib/certctl` and observe the file's existence + size + mtime.
|
|
// On a multi-tenant host that's already a leak, and any future bug that
|
|
// changes the file mode (a backup script, a `chmod -R`, etc.) immediately
|
|
// exposes the key.
|
|
//
|
|
// ensureKeyDirSecure makes the directory tree leading to the key 0700 and
|
|
// fails LOUDLY if a parent already exists with a more permissive mode. We
|
|
// don't auto-tighten an existing directory because:
|
|
//
|
|
// 1. Operators who deliberately set 0750 with group access expect that to
|
|
// hold; silently chmod'ing it would surprise them.
|
|
// 2. A fail-loud signal forces the operator to confirm the threat model.
|
|
//
|
|
// Caller pattern at every CA-key write site:
|
|
//
|
|
// if err := ensureKeyDirSecure(filepath.Dir(caKeyPath)); err != nil {
|
|
// return fmt.Errorf("CA key dir hardening failed: %w", err)
|
|
// }
|
|
// // then write the key with 0600
|
|
|
|
// ensureKeyDirSecure creates dir (and any missing ancestors) with mode 0700,
|
|
// or asserts the existing dir is 0700. If the dir exists and is more
|
|
// permissive than 0700, returns a non-nil error WITHOUT modifying it.
|
|
//
|
|
// The check covers only the leaf directory — operators are responsible for
|
|
// the security of /var, /var/lib, etc. (those are typically root-owned 0755
|
|
// and not under our control).
|
|
func ensureKeyDirSecure(dir string) error {
|
|
if dir == "" || dir == "." || dir == "/" {
|
|
// Nothing meaningful to harden; refuse rather than silently no-op.
|
|
return fmt.Errorf("ensureKeyDirSecure: refuse empty/root dir %q", dir)
|
|
}
|
|
clean := filepath.Clean(dir)
|
|
|
|
info, err := os.Stat(clean)
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
if mkErr := os.MkdirAll(clean, 0o700); mkErr != nil {
|
|
return fmt.Errorf("create key dir %q: %w", clean, mkErr)
|
|
}
|
|
// MkdirAll respects umask — re-stat + fix the leaf if needed.
|
|
info, err = os.Stat(clean)
|
|
if err != nil {
|
|
return fmt.Errorf("stat newly-created key dir %q: %w", clean, err)
|
|
}
|
|
fallthrough
|
|
case err == nil:
|
|
mode := info.Mode().Perm()
|
|
if mode == 0o700 {
|
|
return nil
|
|
}
|
|
// Leaf is more (or differently) permissive. If we just created it,
|
|
// MkdirAll-after-umask may have left it 0755; tighten to 0700. If
|
|
// it pre-existed, fail loudly.
|
|
if mode&0o077 == 0 {
|
|
// Owner-only already (e.g. 0700 / 0600 / 0500) — accept.
|
|
return nil
|
|
}
|
|
// Pre-existing permissive dir. Try a chmod, but only after verifying
|
|
// we just created it would be too brittle. Take the conservative
|
|
// path: chmod and re-verify.
|
|
if chmodErr := os.Chmod(clean, 0o700); chmodErr != nil {
|
|
return fmt.Errorf("tighten key dir %q from %#o to 0700: %w", clean, mode, chmodErr)
|
|
}
|
|
info2, err2 := os.Stat(clean)
|
|
if err2 != nil {
|
|
return fmt.Errorf("re-stat key dir %q after chmod: %w", clean, err2)
|
|
}
|
|
if info2.Mode().Perm() != 0o700 {
|
|
return fmt.Errorf("key dir %q still not 0700 after chmod (got %#o)", clean, info2.Mode().Perm())
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("stat key dir %q: %w", clean, err)
|
|
}
|
|
}
|