mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 13:58:51 +00:00
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:
@@ -0,0 +1,857 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Bundle-9 / Audit H-010 + L-002 + L-003 + L-012 + M-028 regression suite.
|
||||
//
|
||||
// Goal: lift internal/connector/issuer/local/ coverage from the pre-bundle
|
||||
// baseline (68.3%) to ≥85% by exercising the previously untested paths:
|
||||
//
|
||||
// GetCACertPEM (0.0%) — happy path + uninitialized-CA path
|
||||
// GetRenewalInfo (0.0%) — returns nil + true (current behavior)
|
||||
// parsePrivateKey (27.3%) — RSA / ECDSA EC / PKCS8-RSA / PKCS8-ECDSA
|
||||
// / unknown type / non-signer PKCS8 / malformed
|
||||
// resolveEKUsAndKeyUsage (10.0%) — empty list / each individual EKU /
|
||||
// unknown EKU / mixed TLS+email
|
||||
// hashPublicKey (44.4%) — RSA / ECDSA-P256 / ECDSA-P384 /
|
||||
// ECDSA-P521 / unsupported curve
|
||||
// ecdsaToECDH (0.0%) — round-trip pin: byte-identical to
|
||||
// legacy elliptic.Marshal output
|
||||
// validateCSRUnicode (58.3%) — every rejection arm + clean-pass arm
|
||||
// keymem.go / keystore.go (0.0%) — every branch
|
||||
//
|
||||
// We also exercise IssueCertificate / RenewCertificate failure paths
|
||||
// (malformed PEM, invalid CSR signature, post-rejection unicode) to lift
|
||||
// those out of the high-50s. The bundle's promised floor is 85%; we aim
|
||||
// for headroom.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestConnectorBundle9(t *testing.T) *Connector {
|
||||
t.Helper()
|
||||
c := New(&Config{ValidityDays: 7}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := c.ensureCA(context.Background()); err != nil {
|
||||
t.Fatalf("ensureCA: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func mustGenECDSAKey(t *testing.T, curve elliptic.Curve) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func mustGenRSAKey(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func mustEncodeCSR(t *testing.T, key any, tmpl *x509.CertificateRequest) string {
|
||||
t.Helper()
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create csr: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetCACertPEM / GetRenewalInfo (lift 0% → 100%)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetCACertPEM_ReturnsAfterEnsureCA(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
pemStr, err := c.GetCACertPEM(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM err: %v", err)
|
||||
}
|
||||
if !strings.Contains(pemStr, "-----BEGIN CERTIFICATE-----") {
|
||||
t.Errorf("expected PEM CA cert, got %q", pemStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCACertPEM_TriggersEnsureCAOnFreshConnector(t *testing.T) {
|
||||
// Fresh connector — GetCACertPEM should call ensureCA implicitly.
|
||||
c := New(&Config{ValidityDays: 7}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
pemStr, err := c.GetCACertPEM(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM on fresh connector: %v", err)
|
||||
}
|
||||
if pemStr == "" {
|
||||
t.Fatal("expected non-empty PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRenewalInfo_ReturnsNilNil(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
info, err := c.GetRenewalInfo(context.Background(), "any-cert-pem")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo err: %v", err)
|
||||
}
|
||||
if info != nil {
|
||||
t.Errorf("expected nil RenewalInfo for local CA (no ARI support), got %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePrivateKey (27.3% → all branches)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParsePrivateKey_RSAPKCS1(t *testing.T) {
|
||||
k := mustGenRSAKey(t)
|
||||
der := x509.MarshalPKCS1PrivateKey(k)
|
||||
signer, err := parsePrivateKey(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey RSA PKCS1: %v", err)
|
||||
}
|
||||
if _, ok := signer.(*rsa.PrivateKey); !ok {
|
||||
t.Errorf("expected *rsa.PrivateKey, got %T", signer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_ECPrivateKey(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
der, err := x509.MarshalECPrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
signer, err := parsePrivateKey(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey EC: %v", err)
|
||||
}
|
||||
if _, ok := signer.(*ecdsa.PrivateKey); !ok {
|
||||
t.Errorf("expected *ecdsa.PrivateKey, got %T", signer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8RSA(t *testing.T) {
|
||||
k := mustGenRSAKey(t)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkcs8: %v", err)
|
||||
}
|
||||
signer, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey PKCS8: %v", err)
|
||||
}
|
||||
if _, ok := signer.(*rsa.PrivateKey); !ok {
|
||||
t.Errorf("expected RSA, got %T", signer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8ECDSA(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkcs8: %v", err)
|
||||
}
|
||||
signer, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey PKCS8 ECDSA: %v", err)
|
||||
}
|
||||
if _, ok := signer.(*ecdsa.PrivateKey); !ok {
|
||||
t.Errorf("expected ECDSA, got %T", signer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_UnknownType(t *testing.T) {
|
||||
_, err := parsePrivateKey(&pem.Block{Type: "DSA PRIVATE KEY", Bytes: []byte{1, 2, 3}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on unknown PEM type")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported private key type") {
|
||||
t.Errorf("error should mention unsupported, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_MalformedPKCS8(t *testing.T) {
|
||||
_, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{0xff, 0xff, 0xff}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed PKCS8")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveEKUsAndKeyUsage (10% → all branches)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveEKUsAndKeyUsage_EmptyDefaultsToTLS(t *testing.T) {
|
||||
ekus, usage := resolveEKUsAndKeyUsage(nil)
|
||||
if len(ekus) != 2 {
|
||||
t.Errorf("expected default serverAuth+clientAuth, got %d EKUs: %v", len(ekus), ekus)
|
||||
}
|
||||
if usage&x509.KeyUsageDigitalSignature == 0 {
|
||||
t.Error("expected DigitalSignature in default key usage")
|
||||
}
|
||||
if usage&x509.KeyUsageKeyEncipherment == 0 {
|
||||
t.Error("expected KeyEncipherment in default key usage (TLS server EKU)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEKUsAndKeyUsage_ServerAuthOnly(t *testing.T) {
|
||||
ekus, _ := resolveEKUsAndKeyUsage([]string{"serverAuth"})
|
||||
if len(ekus) != 1 || ekus[0] != x509.ExtKeyUsageServerAuth {
|
||||
t.Errorf("expected only serverAuth, got: %v", ekus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEKUsAndKeyUsage_AllKnownEKUs(t *testing.T) {
|
||||
// ekuNameToX509 supports: serverAuth, clientAuth, codeSigning,
|
||||
// emailProtection, timeStamping. OCSPSigning is intentionally not
|
||||
// in the local-CA allowlist (responder cert is signed by the same
|
||||
// CA but issued via the OCSP path, not the EKU enum).
|
||||
known := []string{"serverAuth", "clientAuth", "codeSigning", "emailProtection", "timeStamping"}
|
||||
ekus, usage := resolveEKUsAndKeyUsage(known)
|
||||
if len(ekus) != len(known) {
|
||||
t.Errorf("expected %d EKUs, got %d: %v", len(known), len(ekus), ekus)
|
||||
}
|
||||
if usage&x509.KeyUsageContentCommitment == 0 {
|
||||
t.Error("expected non-repudiation set when emailProtection is in mix")
|
||||
}
|
||||
if usage&x509.KeyUsageKeyEncipherment == 0 {
|
||||
t.Error("expected KeyEncipherment set when serverAuth is in mix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEKUsAndKeyUsage_AllUnknownFallsBackToDefault(t *testing.T) {
|
||||
ekus, usage := resolveEKUsAndKeyUsage([]string{"madeUp1", "madeUp2"})
|
||||
if len(ekus) != 2 {
|
||||
t.Errorf("expected 2 default EKUs after fallback, got %d", len(ekus))
|
||||
}
|
||||
if usage&x509.KeyUsageDigitalSignature == 0 {
|
||||
t.Error("expected DigitalSignature in fallback default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEKUsAndKeyUsage_UnknownEKUIgnored(t *testing.T) {
|
||||
ekus, _ := resolveEKUsAndKeyUsage([]string{"serverAuth", "totallyMadeUp"})
|
||||
if len(ekus) != 1 || ekus[0] != x509.ExtKeyUsageServerAuth {
|
||||
t.Errorf("unknown EKU should be silently dropped, got: %v", ekus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEKUsAndKeyUsage_EmailOnlyHasNoKeyEncipherment(t *testing.T) {
|
||||
_, usage := resolveEKUsAndKeyUsage([]string{"emailProtection"})
|
||||
if usage&x509.KeyUsageKeyEncipherment != 0 {
|
||||
t.Error("email-only should NOT include KeyEncipherment")
|
||||
}
|
||||
if usage&x509.KeyUsageContentCommitment == 0 {
|
||||
t.Error("email-only SHOULD include ContentCommitment (non-repudiation)")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hashPublicKey (44.4% → all curves) + ecdsaToECDH (0% → all curves)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHashPublicKey_RSA(t *testing.T) {
|
||||
k := mustGenRSAKey(t)
|
||||
out := hashPublicKey(&k.PublicKey)
|
||||
if len(out) != 4 {
|
||||
t.Errorf("expected 4-byte SKI prefix, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPublicKey_ECDSA_P256(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
out := hashPublicKey(&k.PublicKey)
|
||||
if len(out) != 4 {
|
||||
t.Errorf("expected 4-byte SKI prefix, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPublicKey_ECDSA_P384(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P384())
|
||||
_ = hashPublicKey(&k.PublicKey)
|
||||
}
|
||||
|
||||
func TestHashPublicKey_ECDSA_P521(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P521())
|
||||
_ = hashPublicKey(&k.PublicKey)
|
||||
}
|
||||
|
||||
func TestHashPublicKey_UnknownTypeReturnsEmpty(t *testing.T) {
|
||||
type bogusPub struct{}
|
||||
out := hashPublicKey(bogusPub{})
|
||||
if len(out) != 4 {
|
||||
t.Errorf("expected 4-byte hash even for empty input (sha256 prefix), got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPublicKey_ECDSA_RoundTripPin asserts that the new
|
||||
// crypto/ecdh-based encoding produces byte-identical output to the legacy
|
||||
// elliptic.Marshal call this PR removed (M-028 SA1019 migration). If this
|
||||
// test fails, the SubjectKeyId of every certificate the local CA has ever
|
||||
// issued would silently change on upgrade, breaking pinning + audit
|
||||
// fingerprinting downstream.
|
||||
func TestHashPublicKey_ECDSA_RoundTripPin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
curve elliptic.Curve
|
||||
}{
|
||||
{"P256", elliptic.P256()},
|
||||
{"P384", elliptic.P384()},
|
||||
{"P521", elliptic.P521()},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, tc.curve)
|
||||
ecdhPub, err := ecdsaToECDH(&k.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsaToECDH: %v", err)
|
||||
}
|
||||
ecdhBytes := ecdhPub.Bytes()
|
||||
//nolint:staticcheck // SA1019: pin assertion — we DELIBERATELY use
|
||||
// the deprecated API here as a regression oracle to prove the
|
||||
// new crypto/ecdh path produces byte-identical output. If
|
||||
// elliptic.Marshal is removed in a future Go release this test
|
||||
// must be deleted (and the migration is then irreversibly proven).
|
||||
legacy := elliptic.Marshal(k.Curve, k.X, k.Y)
|
||||
if !bytes.Equal(ecdhBytes, legacy) {
|
||||
t.Fatalf("ECDH .Bytes() != legacy elliptic.Marshal output\n new: %x\n old: %x", ecdhBytes, legacy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcdsaToECDH_RejectsP224(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P224())
|
||||
_, err := ecdsaToECDH(&k.PublicKey)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported-curve error for P-224")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported curve") {
|
||||
t.Errorf("expected unsupported-curve error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcdsaToECDH_RejectsNilKey(t *testing.T) {
|
||||
if _, err := ecdsaToECDH(nil); err == nil {
|
||||
t.Fatal("expected error on nil key")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateCSRUnicode (58% → all branches)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateCSRUnicode_CleanPasses(t *testing.T) {
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "example.com"},
|
||||
DNSNames: []string{"www.example.com", "api.example.com"},
|
||||
EmailAddresses: []string{"admin@example.com"},
|
||||
}
|
||||
if err := validateCSRUnicode(csr, []string{"alt.example.com"}); err != nil {
|
||||
t.Errorf("clean CSR rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSRUnicode_RejectsCNHomograph(t *testing.T) {
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "аpple.com"}, // Cyrillic а
|
||||
}
|
||||
err := validateCSRUnicode(csr, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for CN homograph")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CommonName") {
|
||||
t.Errorf("error should mention CommonName, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSRUnicode_RejectsDNSNameRTL(t *testing.T) {
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ok.com"},
|
||||
DNSNames: []string{"goodevil.com"},
|
||||
}
|
||||
err := validateCSRUnicode(csr, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for DNSName RTL override")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DNSNames") {
|
||||
t.Errorf("error should mention DNSNames, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSRUnicode_RejectsEmailZeroWidth(t *testing.T) {
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ok.com"},
|
||||
EmailAddresses: []string{"goodbad@example.com"},
|
||||
}
|
||||
err := validateCSRUnicode(csr, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for email zero-width")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "EmailAddresses") {
|
||||
t.Errorf("error should mention EmailAddresses, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSRUnicode_RejectsAdditionalSAN(t *testing.T) {
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ok.com"},
|
||||
}
|
||||
err := validateCSRUnicode(csr, []string{"goodevil.com"})
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for additional SAN RTL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request SANs") {
|
||||
t.Errorf("error should mention request SANs, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueCertificate / RenewCertificate failure paths (lift 55-68% → higher)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIssueCertificate_RejectsMalformedCSRPEM(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||
CommonName: "x.com",
|
||||
CSRPEM: "not a pem",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed CSR PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCertificate_RejectsBadCSRSignature(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
// Build a valid CSR using key A, then re-sign the CertificateRequest
|
||||
// payload with key B (or just flip bytes in the signature) — the
|
||||
// CheckSignature path inside IssueCertificate must reject this.
|
||||
keyA := mustGenECDSAKey(t, elliptic.P256())
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "x.com"},
|
||||
}, keyA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Flip a byte deep in the signature (last 16 bytes are signature octets).
|
||||
if len(der) < 20 {
|
||||
t.Skip("unexpectedly short DER")
|
||||
}
|
||||
der[len(der)-5] ^= 0xff
|
||||
tamperedPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||
_, issErr := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||
CommonName: "x.com",
|
||||
CSRPEM: tamperedPEM,
|
||||
})
|
||||
if issErr == nil {
|
||||
t.Fatal("expected error on tampered CSR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCertificate_RejectsHomographCSR(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
csrPEM := mustEncodeCSR(t, k, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "аpple.com"},
|
||||
})
|
||||
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||
CommonName: "аpple.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unicode-rejection error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CommonName") {
|
||||
t.Errorf("expected CommonName-cited error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewCertificate_RejectsMalformedCSRPEM(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
_, err := c.RenewCertificate(context.Background(), issuer.RenewalRequest{
|
||||
CommonName: "x.com",
|
||||
CSRPEM: "not a pem",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed CSR PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewCertificate_RejectsHomographCSR(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
csrPEM := mustEncodeCSR(t, k, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "аpple.com"},
|
||||
})
|
||||
_, err := c.RenewCertificate(context.Background(), issuer.RenewalRequest{
|
||||
CommonName: "аpple.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unicode-rejection error on renew")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewCertificate_HappyPath(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
csrPEM := mustEncodeCSR(t, k, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "renew.example.com"},
|
||||
})
|
||||
res, err := c.RenewCertificate(context.Background(), issuer.RenewalRequest{
|
||||
CommonName: "renew.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renew failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(res.CertPEM, "BEGIN CERTIFICATE") {
|
||||
t.Errorf("expected cert PEM, got: %s", res.CertPEM)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// keymem.go — marshalPrivateKeyAndZeroize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMarshalPrivateKeyAndZeroize_HappyPath(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
var captured []byte
|
||||
err := marshalPrivateKeyAndZeroize(k, func(der []byte) error {
|
||||
// Take a defensive copy — we promise NOT to retain `der`, but for
|
||||
// the test we want to inspect it AFTER the function returns to
|
||||
// prove zeroization happened to the underlying buffer.
|
||||
captured = make([]byte, len(der))
|
||||
copy(captured, der)
|
||||
// Verify the DER decodes correctly while we have it.
|
||||
if _, parseErr := x509.ParseECPrivateKey(der); parseErr != nil {
|
||||
t.Errorf("DER inside callback should parse: %v", parseErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Captured bytes should still be valid PKCS-DER (we copied them).
|
||||
if _, err := x509.ParseECPrivateKey(captured); err != nil {
|
||||
t.Errorf("captured copy should still parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPrivateKeyAndZeroize_NilKey(t *testing.T) {
|
||||
err := marshalPrivateKeyAndZeroize(nil, func([]byte) error { return nil })
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPrivateKeyAndZeroize_OnDERError(t *testing.T) {
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
wantErr := errors.New("simulated downstream failure")
|
||||
gotErr := marshalPrivateKeyAndZeroize(k, func([]byte) error { return wantErr })
|
||||
if !errors.Is(gotErr, wantErr) {
|
||||
t.Errorf("expected error to propagate, got: %v", gotErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// keystore.go — ensureKeyDirSecure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEnsureKeyDirSecure_CreatesNewDir(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
tmp := filepath.Join(t.TempDir(), "fresh")
|
||||
if err := ensureKeyDirSecure(tmp); err != nil {
|
||||
t.Fatalf("ensureKeyDirSecure: %v", err)
|
||||
}
|
||||
info, err := os.Stat(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected 0700 after ensure, got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyDirSecure_AcceptsExisting0700(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
// t.TempDir creates 0700 on unix.
|
||||
_ = os.Chmod(dir, 0o700)
|
||||
if err := ensureKeyDirSecure(dir); err != nil {
|
||||
t.Errorf("0700 dir should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyDirSecure_TightensPermissive(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := ensureKeyDirSecure(dir); err != nil {
|
||||
t.Fatalf("ensureKeyDirSecure should tighten: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected 0700 after tighten, got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyDirSecure_RejectsEmpty(t *testing.T) {
|
||||
if err := ensureKeyDirSecure(""); err == nil {
|
||||
t.Error("expected refusal of empty path")
|
||||
}
|
||||
if err := ensureKeyDirSecure("/"); err == nil {
|
||||
t.Error("expected refusal of root")
|
||||
}
|
||||
if err := ensureKeyDirSecure("."); err == nil {
|
||||
t.Error("expected refusal of dot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyDirSecure_AcceptsOwnerOnlyMode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o500); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := ensureKeyDirSecure(dir); err != nil {
|
||||
t.Errorf("0500 (owner-only no-write) should be accepted: %v", err)
|
||||
}
|
||||
// Restore so t.TempDir cleanup works.
|
||||
_ = os.Chmod(dir, 0o700)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadCAFromDisk negative paths (lift to push total over 85%)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLoadCAFromDisk_RejectsExpiredCA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caKey := mustGenECDSAKey(t, elliptic.P256())
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "expired-ca"},
|
||||
NotBefore: time.Now().Add(-2 * time.Hour),
|
||||
NotAfter: time.Now().Add(-1 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPath := filepath.Join(dir, "ca.crt")
|
||||
keyPath := filepath.Join(dir, "ca.key")
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, _ := x509.MarshalECPrivateKey(caKey)
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := New(&Config{ValidityDays: 7, CACertPath: certPath, CAKeyPath: keyPath}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
err = c.ensureCA(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired CA")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expired") {
|
||||
t.Errorf("expected expired-CA error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCAFromDisk_RejectsNonCACert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caKey := mustGenECDSAKey(t, elliptic.P256())
|
||||
// IsCA: false -> should be rejected
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "not-a-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: false,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPath := filepath.Join(dir, "ca.crt")
|
||||
keyPath := filepath.Join(dir, "ca.key")
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, _ := x509.MarshalECPrivateKey(caKey)
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := New(&Config{ValidityDays: 7, CACertPath: certPath, CAKeyPath: keyPath}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
err = c.ensureCA(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-CA cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCAFromDisk_HappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caKey := mustGenECDSAKey(t, elliptic.P256())
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(3),
|
||||
Subject: pkix.Name{CommonName: "valid-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPath := filepath.Join(dir, "ca.crt")
|
||||
keyPath := filepath.Join(dir, "ca.key")
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, _ := x509.MarshalECPrivateKey(caKey)
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := New(&Config{ValidityDays: 7, CACertPath: certPath, CAKeyPath: keyPath}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := c.ensureCA(context.Background()); err != nil {
|
||||
t.Fatalf("loadCAFromDisk happy: %v", err)
|
||||
}
|
||||
if !c.subCA {
|
||||
t.Error("expected subCA=true after disk-load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCAFromDisk_MissingCert(t *testing.T) {
|
||||
c := New(&Config{ValidityDays: 7, CACertPath: "/nope/missing.crt", CAKeyPath: "/nope/missing.key"}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
err := c.ensureCA(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CA file")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Final pushes to clear the ≥85% coverage gate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseIP_ValidAndInvalid(t *testing.T) {
|
||||
if parseIP("10.0.0.1") == nil {
|
||||
t.Error("10.0.0.1 should parse")
|
||||
}
|
||||
if parseIP("not-an-ip") != nil {
|
||||
t.Error("garbage shouldn't parse")
|
||||
}
|
||||
if parseIP("::1") == nil {
|
||||
t.Error("IPv6 ::1 should parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmail_TrueAndFalse(t *testing.T) {
|
||||
// isEmail is a simple "contains @" check — that's the spec it
|
||||
// implements; we just pin both sides of the binary decision.
|
||||
if !isEmail("user@example.com") {
|
||||
t.Error("user@example.com should be an email")
|
||||
}
|
||||
if isEmail("just-a-host.example.com") {
|
||||
t.Error("plain host should not be classified as email")
|
||||
}
|
||||
if isEmail("") {
|
||||
t.Error("empty string should not be classified as email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_AllArms(t *testing.T) {
|
||||
c := New(&Config{ValidityDays: 7}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// Malformed JSON — must fail.
|
||||
if err := c.ValidateConfig(context.Background(), []byte("not json")); err == nil {
|
||||
t.Error("malformed JSON should be rejected")
|
||||
}
|
||||
// Default validity (zero) — must fail (validity_days must be >=1).
|
||||
if err := c.ValidateConfig(context.Background(), []byte(`{"validity_days":0}`)); err == nil {
|
||||
t.Error("validity_days < 1 should be rejected")
|
||||
}
|
||||
// Sub-CA with cert path but no key path — must fail.
|
||||
if err := c.ValidateConfig(context.Background(), []byte(`{"validity_days":7,"ca_cert_path":"/x"}`)); err == nil {
|
||||
t.Error("sub-CA with only cert path should be rejected")
|
||||
}
|
||||
// Sub-CA with key path but no cert path — must fail.
|
||||
if err := c.ValidateConfig(context.Background(), []byte(`{"validity_days":7,"ca_key_path":"/x"}`)); err == nil {
|
||||
t.Error("sub-CA with only key path should be rejected")
|
||||
}
|
||||
// Sub-CA with both paths but pointing nowhere — must fail (Stat).
|
||||
if err := c.ValidateConfig(context.Background(), []byte(`{"validity_days":7,"ca_cert_path":"/nope","ca_key_path":"/nope-key"}`)); err == nil {
|
||||
t.Error("sub-CA with non-existent paths should be rejected")
|
||||
}
|
||||
// Self-signed mode with valid validity — must pass.
|
||||
if err := c.ValidateConfig(context.Background(), []byte(`{"validity_days":7}`)); err != nil {
|
||||
t.Errorf("self-signed valid config should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCertificate_WithMaxTTLCap(t *testing.T) {
|
||||
c := newTestConnectorBundle9(t)
|
||||
k := mustGenECDSAKey(t, elliptic.P256())
|
||||
csrPEM := mustEncodeCSR(t, k, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ttl.example.com"},
|
||||
DNSNames: []string{"ttl.example.com"},
|
||||
IPAddresses: []net.IP{net.ParseIP("10.0.0.5")},
|
||||
EmailAddresses: []string{"ops@ttl.example.com"},
|
||||
})
|
||||
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||
CommonName: "ttl.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
MaxTTLSeconds: 3600, // 1h cap
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue failed: %v", err)
|
||||
}
|
||||
if got := res.NotAfter.Sub(res.NotBefore); got > time.Hour+time.Minute {
|
||||
t.Errorf("MaxTTL cap not honored, got window %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user