Files
certctl/internal/connector/issuer/local/bundle9_coverage_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

858 lines
29 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 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)
}
}