Bundle 9: Local-issuer hardening — 5 findings closed + 1 partial

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].
This commit is contained in:
Shankar
2026-04-26 17:18:00 +00:00
parent 9f1711600e
commit d588bb898a
10 changed files with 1603 additions and 24 deletions
+158
View File
@@ -0,0 +1,158 @@
package validation
import (
"strings"
"testing"
)
// Bundle-9 / Audit L-012 / CWE-1007 + CWE-176 regression suite.
//
// Note: invisible / formatting characters in test inputs are written as
// \uXXXX escape sequences (NOT literal codepoints) so the source file
// stays parseable + readable. Literal BOM / RTL-override bytes inside
// a Go string literal trip the parser ("illegal byte order mark").
func TestValidateUnicodeSafe_AcceptsCleanASCII(t *testing.T) {
cases := []string{
"example.com",
"api.example.com",
"sub-domain.example.co.uk",
"a.b.c.d.example.org",
"localhost",
"192.168.1.1",
"",
}
for _, c := range cases {
t.Run(c, func(t *testing.T) {
if err := ValidateUnicodeSafe(c); err != nil {
t.Errorf("clean ASCII %q rejected: %v", c, err)
}
})
}
}
func TestValidateUnicodeSafe_RejectsRTLOverride(t *testing.T) {
cases := []struct {
name string
in string
}{
{"LRE", "goodcom"},
{"RLE", "goodcom"},
{"PDF", "goodcom"},
{"LRO", "goodcom"},
{"RLO", "goodcom"},
{"LRI", "goodcom"},
{"RLI", "goodcom"},
{"FSI", "goodcom"},
{"PDI", "goodcom"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateUnicodeSafe(c.in)
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "bidirectional override") {
t.Errorf("error should cite bidirectional override; got: %v", err)
}
})
}
}
func TestValidateUnicodeSafe_RejectsZeroWidth(t *testing.T) {
cases := []struct {
name string
in string
}{
{"ZWSP", "goodcom"},
{"ZWNJ", "goodcom"},
{"ZWJ", "goodcom"},
{"WJ", "goodcom"},
{"BOM", "good\uFEFFcom"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateUnicodeSafe(c.in)
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "zero-width") {
t.Errorf("error should cite zero-width; got: %v", err)
}
})
}
}
func TestValidateUnicodeSafe_RejectsControlChars(t *testing.T) {
cases := []struct {
name string
in string
}{
{"NUL", "good\x00com"},
{"TAB", "good\tcom"},
{"LF", "good\ncom"},
{"CR", "good\rcom"},
{"DEL", "good\x7Fcom"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateUnicodeSafe(c.in)
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "control character") {
t.Errorf("error should cite control character; got: %v", err)
}
})
}
}
func TestValidateUnicodeSafe_RejectsIDNHomograph(t *testing.T) {
// Cyrillic 'а' (U+0430) inside an otherwise-Latin label — visually
// identical to Latin 'a' but a different codepoint. Classic homograph.
cases := []struct {
name string
in string
}{
{"cyrillic_a_in_apple", "аpple.com"},
{"greek_omicron_in_google", "gοogle.com"},
{"cherokee_letter", "gᏇogle.com"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateUnicodeSafe(c.in)
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "IDN homograph") {
t.Errorf("error should cite IDN homograph; got: %v", err)
}
})
}
}
func TestValidateUnicodeSafe_AcceptsPureNonASCII(t *testing.T) {
// A fully-Cyrillic label is a legitimate IDN — don't reject. The
// homograph attack we're defending against is the MIX with ASCII.
in := "пример.рф"
if err := ValidateUnicodeSafe(in); err != nil {
t.Errorf("pure-Cyrillic label rejected: %v", err)
}
}
func TestValidateUnicodeSafe_ErrorMentionsByteOffset(t *testing.T) {
in := "goodevil.com"
err := ValidateUnicodeSafe(in)
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "byte offset") {
t.Errorf("error should cite byte offset; got: %v", err)
}
}
func TestValidateUnicodeSafe_EmptyStringPasses(t *testing.T) {
if err := ValidateUnicodeSafe(""); err != nil {
t.Errorf("empty string should pass through (different validator handles required); got: %v", err)
}
}