Files
certctl/internal/crypto/signer/file_driver.go
T
Shankar fdd445c09f 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.
2026-04-28 22:03:55 +00:00

222 lines
7.7 KiB
Go

package signer
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
)
// FileDriver materializes a Signer from a PEM-encoded private key on
// disk. This is the historical and current default behavior of the
// local issuer; FileDriver wraps that behavior without functional
// change so the local issuer can route every signing call through the
// Signer interface without changing what bytes land on disk.
//
// SECURITY: callers SHOULD set DirHardener and Marshaler to enforce
// the audited Bundle 9 hardening (key directory mode 0700 via
// keystore.ensureKeyDirSecure; marshal-with-zeroization via
// keymem.marshalPrivateKeyAndZeroize). When DirHardener is unset,
// Generate refuses to write — an explicit fail-loud signal rather
// than silently falling back to a permissive directory mode.
//
// Load does NOT call DirHardener (Load is read-only and the key may
// already exist in a directory whose mode the operator chose
// deliberately for their threat model). Load also does not call
// Marshaler (Load doesn't write anything).
type FileDriver struct {
// DirHardener, if set, is invoked on the directory containing a
// generated key file BEFORE the key is written. The local
// package wires this to keystore.ensureKeyDirSecure (via a closure
// — the helper stays package-private to preserve the audit trail
// in keystore.go's leading comment block). When nil, Generate
// returns an error.
DirHardener func(dir string) error
// Marshaler, if set, converts an *ecdsa.PrivateKey to the
// PEM-encoded byte slice that Generate will write to disk. The
// local package wires this to a wrapper around
// keymem.marshalPrivateKeyAndZeroize, ensuring the L-002
// heap-zeroization discipline applies to all keys generated
// through this driver. When nil, Generate falls back to a
// non-zeroizing marshal — acceptable for tests but NOT for
// production code paths.
Marshaler func(*ecdsa.PrivateKey) ([]byte, error)
// RSAMarshaler is the same shape as Marshaler but for RSA keys.
// Optional; if nil, Generate falls back to a non-zeroizing
// marshal. Provided for symmetry with Marshaler so the local
// issuer can plug in RSA-key-zeroization later without changing
// the FileDriver API.
RSAMarshaler func(*rsa.PrivateKey) ([]byte, error)
// GenerateOutPath, if set, is called with the generated key's
// algorithm and returns the destination path. When nil, Generate
// uses a default of <cwd>/ca-<alg>.key — fine for tests, NOT for
// production. The local package's NewConnector wires this to
// return the configured CAKeyPath.
GenerateOutPath func(alg Algorithm) (string, error)
}
// Name implements Driver.
func (d *FileDriver) Name() string { return "file" }
// Load implements Driver. It reads the PEM file at path, decodes the
// first PEM block, parses it via the package's parsePrivateKey
// (which handles PKCS#1 / SEC 1 / PKCS#8), and wraps the resulting
// crypto.Signer.
//
// Errors are wrapped with the path so operators can grep their logs.
// No key bytes are logged — only the path and (on success) the
// inferred Algorithm.
func (d *FileDriver) Load(ctx context.Context, path string) (Signer, error) {
if path == "" {
return nil, errors.New("signer.FileDriver.Load: empty path")
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
}
pemBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", path, err)
}
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", path)
}
key, err := parsePrivateKey(block)
if err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", path, err)
}
wrapped, err := Wrap(key)
if err != nil {
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", path, err)
}
return wrapped, nil
}
// Generate implements Driver. It generates a fresh private key with the
// requested algorithm, writes it to disk via the configured hooks, and
// returns the wrapped Signer plus the file path the caller can pass
// to a subsequent Load call.
//
// Refuses to write when DirHardener is unset — the production local
// package always wires the hardener; only tests are allowed to bypass
// it by constructing the FileDriver directly without calling
// NewProductionFileDriver.
func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, string, error) {
if d.DirHardener == nil {
return nil, "", errors.New("signer.FileDriver.Generate: DirHardener is required (set to a key-dir-permission validator) — refusing to write key with default umask")
}
if err := ctx.Err(); err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
}
// Resolve destination path before doing any expensive work.
pathFn := d.GenerateOutPath
if pathFn == nil {
pathFn = func(a Algorithm) (string, error) {
return fmt.Sprintf("ca-%s.key", a), nil
}
}
outPath, err := pathFn(alg)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: resolve out path: %w", err)
}
// Harden the destination directory BEFORE generating the key. If
// the directory check fails we bail without touching cryptography.
if err := d.DirHardener(filepath.Dir(outPath)); err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", outPath, err)
}
// Generate the key for the requested algorithm.
var (
signerKey crypto.Signer
pemBytes []byte
)
switch alg {
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
bits := rsaBitsFor(alg)
rsaKey, gerr := rsa.GenerateKey(rand.Reader, bits)
if gerr != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: rsa keygen %d: %w", bits, gerr)
}
signerKey = rsaKey
if d.RSAMarshaler != nil {
pemBytes, err = d.RSAMarshaler(rsaKey)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: RSAMarshaler: %w", err)
}
} else {
pemBytes = pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
})
}
case AlgorithmECDSAP256, AlgorithmECDSAP384:
curve := ecCurveFor(alg)
ecKey, gerr := ecdsa.GenerateKey(curve, rand.Reader)
if gerr != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: ecdsa keygen %s: %w", curve.Params().Name, gerr)
}
signerKey = ecKey
if d.Marshaler != nil {
pemBytes, err = d.Marshaler(ecKey)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: Marshaler: %w", err)
}
} else {
der, mErr := x509.MarshalECPrivateKey(ecKey)
if mErr != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: marshal ec key: %w", mErr)
}
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
}
default:
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w: %s", ErrUnsupportedAlgorithm, alg)
}
// Write 0o600 — owner-read-write only. Any read by group/other is
// a configuration regression; the dir 0700 above prevents
// enumeration of the file's existence.
if err := os.WriteFile(outPath, pemBytes, 0o600); err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", outPath, err)
}
wrapped, err := Wrap(signerKey)
if err != nil {
return nil, "", fmt.Errorf("signer.FileDriver.Generate: wrap: %w", err)
}
return wrapped, outPath, nil
}
func rsaBitsFor(a Algorithm) int {
switch a {
case AlgorithmRSA3072:
return 3072
case AlgorithmRSA4096:
return 4096
default:
return 2048
}
}
func ecCurveFor(a Algorithm) elliptic.Curve {
if a == AlgorithmECDSAP384 {
return elliptic.P384()
}
return elliptic.P256()
}