mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 04:28:56 +00:00
fdd445c09f
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.
222 lines
7.7 KiB
Go
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()
|
|
}
|