mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 14:18:52 +00:00
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:
@@ -0,0 +1,221 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user