Files
certctl/internal/validation/unicode_test.go
T
Shankar d588bb898a 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].
2026-04-26 17:18:00 +00:00

159 lines
4.0 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}