crypto/signer: introduce Signer interface; refactor local issuer to use it

This is a load-bearing internal refactor with no user-visible behavior
change. The new internal/crypto/signer package abstracts CA private-key
signing behind a Signer interface (embeds stdlib crypto.Signer + adds
Algorithm()). The local issuer now consumes this interface; the
historical c.caKey crypto.Signer field is renamed c.caSigner signer.Signer.

What landed:

  * internal/crypto/signer/ — new stdlib-only package
    - Signer interface: crypto.Signer + Algorithm()
    - Algorithm enum: RSA-2048, RSA-3072, RSA-4096, ECDSA-P256, ECDSA-P384
    - Driver interface: Load / Generate / Name
    - FileDriver: production driver, wraps file-on-disk PEM, hooks for
      DirHardener + Marshaler so the local package can inject Bundle 9
      keystore.ensureKeyDirSecure + keymem.marshalPrivateKeyAndZeroize
    - MemoryDriver: in-memory test driver; safe for concurrent use
    - parse.go: ParsePrivateKey moved here from local.go (PKCS#1, SEC 1, PKCS#8)
    - 91.6% coverage (gate ≥85)

  * internal/connector/issuer/local/local.go — refactor
    - Rename c.caKey crypto.Signer → c.caSigner signer.Signer
    - Rewire 4 signing call sites: leaf cert (line ~613), CRL (~849),
      OCSP response (~887), CA bootstrap (~482) — all access the
      interface; the bootstrap also switches to interface-level
      Public() + Signer
    - Wrap freshly-generated and freshly-loaded keys; reject Ed25519
      and other unsupported algorithms at load time (was silently
      accepted before, would have failed at first sign)
    - Delete the duplicated parsePrivateKey helper (single source of
      truth now lives in the signer package)
    - Update the L-014 threat-model comment block (lines 1-29) with a
      forward-reference paragraph: file-on-disk caveats apply only to
      FileDriver-backed signers; alternative drivers close that leg
    - Coverage 86.7 → 86.5 (above CI floor of 86); the 0.2pp drop is
      mechanical from deleting parsePrivateKey, partially recovered by
      a new test pinning the Wrap error path

  * internal/crypto/signer/equivalence_test.go — Phase 3 safety net
    - RSA byte-strict equality for leaf certs / CRLs / OCSP responses
      (PKCS#1 v1.5 is deterministic)
    - ECDSA TBS-strict equality (signature differs because of random k)
    - Both signatures independently validate against the CA
    - Negative sentinel proves the equivalence checker isn't trivially-
      passing

  * docs/architecture.md — new 'CA Signing Abstraction' section under
    Security Model, with ASCII diagram of FileDriver / MemoryDriver /
    future PKCS11Driver / future CloudKMSDriver

  * Test file mechanical edits (only):
    - bundle9_coverage_test.go: parsePrivateKey → signer.ParsePrivateKey
      (function moved, not behavior changed)
    - local_test.go: append one targeted test
      (TestSubCA_LoadCAFromDisk_RejectsUnsupportedKeyAlgorithm) that
      pins the new Wrap error path I introduced — recovers coverage
      cost of the deletion above

What did NOT change (verified empty diffs):
  * api/openapi.yaml
  * migrations/
  * internal/connector/issuer/interface.go
  * go.mod / go.sum (no new dependencies; stdlib only)

This refactor is the prerequisite for three downstream items:
  - PKCS#11/HSM driver (V3-Pro)
  - CRL/OCSP responder (V2)
  - SSH CA lifecycle (V2)

Each of those adds a new signing call site. Doing the abstraction now
costs once; deferring would cost three times.
This commit is contained in:
Shankar
2026-04-28 22:03:55 +00:00
parent 177772929b
commit fdd445c09f
12 changed files with 2057 additions and 47 deletions
@@ -23,6 +23,7 @@ import (
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/crypto/signer"
)
// Bundle-9 / Audit H-010 + L-002 + L-003 + L-012 + M-028 regression suite.
@@ -133,7 +134,7 @@ func TestGetRenewalInfo_ReturnsNilNil(t *testing.T) {
func TestParsePrivateKey_RSAPKCS1(t *testing.T) {
k := mustGenRSAKey(t)
der := x509.MarshalPKCS1PrivateKey(k)
signer, err := parsePrivateKey(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey RSA PKCS1: %v", err)
}
@@ -148,7 +149,7 @@ func TestParsePrivateKey_ECPrivateKey(t *testing.T) {
if err != nil {
t.Fatalf("marshal: %v", err)
}
signer, err := parsePrivateKey(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey EC: %v", err)
}
@@ -163,7 +164,7 @@ func TestParsePrivateKey_PKCS8RSA(t *testing.T) {
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
signer, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey PKCS8: %v", err)
}
@@ -178,7 +179,7 @@ func TestParsePrivateKey_PKCS8ECDSA(t *testing.T) {
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
signer, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
signer, err := signer.ParsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err != nil {
t.Fatalf("parsePrivateKey PKCS8 ECDSA: %v", err)
}
@@ -188,7 +189,7 @@ func TestParsePrivateKey_PKCS8ECDSA(t *testing.T) {
}
func TestParsePrivateKey_UnknownType(t *testing.T) {
_, err := parsePrivateKey(&pem.Block{Type: "DSA PRIVATE KEY", Bytes: []byte{1, 2, 3}})
_, err := signer.ParsePrivateKey(&pem.Block{Type: "DSA PRIVATE KEY", Bytes: []byte{1, 2, 3}})
if err == nil {
t.Fatal("expected error on unknown PEM type")
}
@@ -198,7 +199,7 @@ func TestParsePrivateKey_UnknownType(t *testing.T) {
}
func TestParsePrivateKey_MalformedPKCS8(t *testing.T) {
_, err := parsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{0xff, 0xff, 0xff}})
_, err := signer.ParsePrivateKey(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{0xff, 0xff, 0xff}})
if err == nil {
t.Fatal("expected error on malformed PKCS8")
}
@@ -855,4 +856,3 @@ func TestGenerateCertificate_WithMaxTTLCap(t *testing.T) {
t.Errorf("MaxTTL cap not honored, got window %s", got)
}
}
+59 -40
View File
@@ -1,9 +1,11 @@
// Bundle-9 / Audit L-014 (Document the CA-key-in-process threat model):
//
// The local CA holds its private key in this process's heap (c.caKey field on
// the Connector struct, plus transient allocations during signing). Go does
// not provide a standard mlock equivalent, the GC does not zero released
// memory, and the runtime moves objects between generations during compaction.
// The local CA holds its private key in this process's heap (c.caSigner
// field on the Connector struct — historically c.caKey before the Signer
// abstraction was introduced — plus transient allocations during signing).
// Go does not provide a standard mlock equivalent, the GC does not zero
// released memory, and the runtime moves objects between generations
// during compaction.
//
// Threats this DOES protect against:
// - Disk-at-rest exposure (key file is mode 0600; key dir is enforced 0700
@@ -26,12 +28,26 @@
// reduce the window of exposure but do not close it; the source of truth
// for "the local CA key cannot leave the host process" is HSM-backed
// signing, not heap hygiene.
//
// Defense-in-depth carve-out — the file-on-disk leg:
//
// The above measures harden the file-on-disk + heap-resident key flow
// (signer.FileDriver). The Signer interface in internal/crypto/signer/
// is the seam that lets operators replace this flow entirely:
// - signer.FileDriver: the current behavior (key on disk, hardening above).
// - signer.PKCS11Driver (future): key never leaves the HSM token.
// - signer.CloudKMSDriver (future): key never leaves the cloud KMS.
//
// When the key lives in a hardware token / KMS, the file-on-disk caveats
// above DO NOT APPLY — the key is not on disk and not in the certctl
// process heap. The L-014 threat-model assumptions documented here
// describe the file-driver case; alternative drivers close the
// disk-exposure leg of the threat model.
package local
import (
"context"
"crypto"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/rand"
@@ -52,6 +68,7 @@ import (
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/validation"
)
@@ -104,11 +121,11 @@ type Connector struct {
config *Config
logger *slog.Logger
mu sync.RWMutex
caKey crypto.Signer // RSA or ECDSA private key
caSigner signer.Signer // wraps the historical caKey crypto.Signer; same lifecycle, same heap residency, same L-014 carve-out
caCert *x509.Certificate
caCertPEM string
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
}
// New creates a new local CA connector with the given configuration and logger.
@@ -360,7 +377,7 @@ func (c *Connector) ensureCA(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.caKey != nil {
if c.caSigner != nil {
return nil // CA already initialized
}
@@ -434,13 +451,17 @@ func (c *Connector) loadCAFromDisk() error {
return fmt.Errorf("invalid CA private key PEM")
}
caKey, err := parsePrivateKey(keyBlock)
caKey, err := signer.ParsePrivateKey(keyBlock)
if err != nil {
return fmt.Errorf("failed to parse CA private key: %w", err)
}
caSigner, err := signer.Wrap(caKey)
if err != nil {
return fmt.Errorf("failed to wrap CA private key as signer: %w", err)
}
// Encode CA cert PEM for chain responses
c.caKey = caKey
c.caSigner = caSigner
c.caCert = caCert
c.caCertPEM = string(certPEM)
c.subCA = true
@@ -459,11 +480,22 @@ func (c *Connector) loadCAFromDisk() error {
func (c *Connector) generateSelfSignedCA() error {
c.logger.Info("generating self-signed CA (ephemeral mode)", "common_name", c.config.CACommonName)
// Generate CA private key
// Generate CA private key. RSA-2048 has been the historical default
// since the local issuer shipped; preserving the algorithm here is
// part of the Signer-refactor's no-behavior-change guarantee.
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate CA key: %w", err)
}
// Wrap the freshly-generated key behind the Signer interface so the
// CreateCertificate call below uses the same access pattern as every
// other CA-signing call site (interface-level Public() + Sign()).
// Wrap is infallible for RSA-2048; the err return is propagated for
// completeness against future Algorithm enum changes.
caSigner, err := signer.Wrap(caKey)
if err != nil {
return fmt.Errorf("failed to wrap CA private key as signer: %w", err)
}
// Create CA certificate
caTemplate := &x509.Certificate{
@@ -478,8 +510,11 @@ func (c *Connector) generateSelfSignedCA() error {
IsCA: true,
}
// Self-sign the CA certificate
caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
// Self-sign the CA certificate via the Signer interface. The
// underlying byte sequence is identical to the historical
// (&caKey.PublicKey, caKey) form because Wrap returns a thin
// adapter that delegates Sign and Public to the same crypto.Signer.
caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caSigner.Public(), caSigner)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
@@ -495,7 +530,7 @@ func (c *Connector) generateSelfSignedCA() error {
Bytes: caCertBytes,
})
c.caKey = caKey
c.caSigner = caSigner
c.caCert = caCert
c.caCertPEM = string(caCertPEM)
@@ -506,28 +541,12 @@ func (c *Connector) generateSelfSignedCA() error {
return nil
}
// parsePrivateKey parses a PEM block into an RSA or ECDSA private key.
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
// PKCS#8 — can contain RSA or ECDSA
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse PKCS#8 key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("PKCS#8 key is not a signing key")
}
return signer, nil
default:
return nil, fmt.Errorf("unsupported private key type: %s (expected RSA PRIVATE KEY, EC PRIVATE KEY, or PRIVATE KEY)", block.Type)
}
}
// parsePrivateKey moved to internal/crypto/signer/parse.go as part of the
// Signer abstraction work. The exported wrapper there
// (signer.ParsePrivateKey) is the single source of truth for PEM
// private-key parsing inside certctl. Do not reintroduce a parallel
// implementation here; the loadCAFromDisk path above calls into the
// signer package directly.
// generateCertificate creates an X.509 certificate signed by the local CA.
// It uses the CSR subject and adds any additional SANs from the request.
@@ -610,7 +629,7 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
}
// Sign certificate with CA
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caKey)
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner)
if err != nil {
return nil, "", "", fmt.Errorf("failed to sign certificate: %w", err)
}
@@ -846,7 +865,7 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok
NextUpdate: now.Add(24 * time.Hour),
}
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caKey)
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caSigner)
if err != nil {
return nil, fmt.Errorf("failed to create CRL: %w", err)
}
@@ -884,7 +903,7 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
template.Status = ocsp.Unknown
}
respBytes, err := ocsp.CreateResponse(c.caCert, c.caCert, template, c.caKey)
respBytes, err := ocsp.CreateResponse(c.caCert, c.caCert, template, c.caSigner)
if err != nil {
return nil, fmt.Errorf("failed to create OCSP response: %w", err)
}
@@ -3,6 +3,7 @@ package local_test
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
@@ -1170,3 +1171,90 @@ func TestSignOCSPResponse_SubCA(t *testing.T) {
t.Log("SubCA OCSP response generated successfully")
}
// TestSubCA_LoadCAFromDisk_RejectsUnsupportedKeyAlgorithm pins the new
// signer.Wrap error path introduced when local.go was refactored to
// route every CA-signing call through the Signer interface. The
// historical parsePrivateKey accepted any PKCS#8 key that satisfied
// crypto.Signer (including Ed25519). The new flow keeps that
// parse-time acceptance but adds a Wrap step that enforces the
// certctl-supported algorithm enum (RSA-2048/3072/4096, ECDSA-P256/P384).
//
// This test confirms an Ed25519 sub-CA key fails LOUDLY at load time
// with a clear "wrap CA private key as signer" error — instead of
// either crashing later at sign time or silently producing a cert
// chain certctl cannot revalidate. Pins both:
// - the new error path coverage (recovers the 0.5pp drop introduced
// by the parsePrivateKey deletion)
// - the contract that loaded sub-CA keys MUST be in the supported
// algorithm enum
func TestSubCA_LoadCAFromDisk_RejectsUnsupportedKeyAlgorithm(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
// Build a valid CA cert signed by RSA so cert-validation passes...
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa keygen: %v", err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(42),
Subject: pkix.Name{CommonName: "Mismatched-Key Test CA"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &rsaKey.PublicKey, rsaKey)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPath := filepath.Join(tmpDir, "ca.crt")
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
// ...but write an UNRELATED Ed25519 key to disk. The Connector's
// loadCAFromDisk does not enforce key-cert key match — it only
// validates the cert and parses the key. The newly-introduced
// signer.Wrap step is what rejects Ed25519.
_, edPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 keygen: %v", err)
}
edDER, err := x509.MarshalPKCS8PrivateKey(edPriv)
if err != nil {
t.Fatalf("marshal ed25519 PKCS#8: %v", err)
}
keyPath := filepath.Join(tmpDir, "ca.key")
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: edDER}), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
conn := local.New(&local.Config{
CACommonName: "Mismatched-Key Test CA",
ValidityDays: 90,
CACertPath: certPath,
CAKeyPath: keyPath,
}, logger)
// IssueCertificate triggers ensureCA → loadCAFromDisk → ParsePrivateKey
// (succeeds for Ed25519 PKCS#8) → signer.Wrap (rejects Ed25519).
dummyKey, _ := rsa.GenerateKey(rand.Reader, 2048)
csrTpl := &x509.CertificateRequest{Subject: pkix.Name{CommonName: "leaf.example.com"}}
csrDER, _ := x509.CreateCertificateRequest(rand.Reader, csrTpl, dummyKey)
csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
_, err = conn.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "leaf.example.com",
CSRPEM: csrPEM,
})
if err == nil {
t.Fatal("expected IssueCertificate to fail for Ed25519 sub-CA key, got nil")
}
if !strings.Contains(err.Error(), "wrap CA private key as signer") {
t.Fatalf("expected error to mention 'wrap CA private key as signer', got: %v", err)
}
}