mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +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:
@@ -817,6 +817,32 @@ The control plane only handles public material: certificates, chains, and CSRs.
|
||||
|
||||
**Server keygen mode (`CERTCTL_KEYGEN_MODE=server`, demo only):** The control plane generates RSA-2048 keys server-side within `processRenewalServerKeygen`. Private keys are stored in `certificate_versions.csr_pem`. A log warning is emitted at startup. Use only for Local CA development/demo.
|
||||
|
||||
### CA Signing Abstraction
|
||||
|
||||
The local issuer's CA private key is wrapped behind the `signer.Signer` interface in `internal/crypto/signer/`. Every CA-signing call site — leaf certificate issuance (`x509.CreateCertificate`), CRL generation (`x509.CreateRevocationList`), and OCSP response signing (`ocsp.CreateResponse`) — accesses the key through this interface rather than touching `crypto.Signer` directly. The interface embeds the stdlib `crypto.Signer` and adds a single `Algorithm() Algorithm` method so call sites can pick the matching `x509.SignatureAlgorithm` without reflecting on the concrete key type.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ signer.Driver (pluggable) │
|
||||
├─────────────────────────────────┤
|
||||
internal/connector/issuer/local │ signer.FileDriver (default) │
|
||||
c.caSigner signer.Signer ──────────► │ PEM key on disk │
|
||||
│ │
|
||||
│ signer.MemoryDriver (tests) │
|
||||
│ in-memory only │
|
||||
│ │
|
||||
│ signer.PKCS11Driver (V3-Pro) │
|
||||
│ HSM token (future) │
|
||||
│ │
|
||||
│ signer.CloudKMSDriver (V3-Pro) │
|
||||
│ AWS / GCP / Azure (future) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Today only `FileDriver` (production) and `MemoryDriver` (tests) ship. The interface exists so PKCS#11/HSM and cloud-KMS drivers can land in follow-on packages (`internal/crypto/signer/pkcs11`, etc.) without modifying any call site or any other driver. The L-014 file-on-disk threat-model carve-out documented at the top of `internal/connector/issuer/local/local.go` applies to `FileDriver`-backed signers; alternative drivers that keep the key inside an HSM token or cloud KMS close the disk-exposure leg of the threat model entirely.
|
||||
|
||||
Behavior equivalence between the wrapped Signer and the raw `crypto.Signer` is pinned by `internal/crypto/signer/equivalence_test.go`: RSA signing is byte-strict equal (PKCS#1 v1.5 is deterministic), ECDSA signing is structurally equal (TBSCertificate / TBSRevocationList byte-equal; signature value differs because ECDSA uses random `k`).
|
||||
|
||||
### Authentication
|
||||
|
||||
- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode. Applies to every path under `/api/v1/*`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package signer abstracts the act of producing cryptographic signatures
|
||||
// over digests on behalf of a certificate authority. It exists so that
|
||||
// downstream code (leaf-cert issuance, CRL generation, OCSP response
|
||||
// signing, SSH CA cert signing — anything that today does
|
||||
// x509.CreateCertificate(... caKey)) sees a single interface and does
|
||||
// not need to know whether the underlying private key lives on disk, in
|
||||
// a PKCS#11 token, in an HSM, or in a cloud KMS.
|
||||
//
|
||||
// The Signer interface deliberately embeds the stdlib crypto.Signer
|
||||
// (Sign + Public) and adds a single method, Algorithm, that returns a
|
||||
// value callers can switch on to pick the matching x509.SignatureAlgorithm
|
||||
// without reflecting on the concrete key type. This is the only certctl-
|
||||
// specific addition; everything else is stdlib-compatible — any
|
||||
// crypto.Signer wrapped by this package's Wrap helper becomes a Signer
|
||||
// without per-key-type boilerplate at the call site.
|
||||
//
|
||||
// Driver implementations live in this package today (FileDriver,
|
||||
// MemoryDriver). HSM-backed drivers (PKCS#11, cloud KMS) land in
|
||||
// follow-on packages (e.g., internal/crypto/signer/pkcs11) and consume
|
||||
// this interface unchanged. Adding a driver does not require modifying
|
||||
// any existing call site or any other driver.
|
||||
//
|
||||
// Threat-model note: Signer wraps a crypto.Signer; the bytes-in-process
|
||||
// hygiene (heap zeroization, no swap, no core-dump exposure) is the
|
||||
// underlying driver's responsibility, not this package's. The L-014
|
||||
// carve-out documented at the top of internal/connector/issuer/local/
|
||||
// local.go applies to FileDriver-backed signers; alternative drivers
|
||||
// (PKCS#11, KMS) close that disk-exposure leg of the threat model
|
||||
// because the key never leaves the token / KMS.
|
||||
package signer
|
||||
@@ -0,0 +1,54 @@
|
||||
package signer
|
||||
|
||||
import "context"
|
||||
|
||||
// Driver knows how to materialize a Signer from some external reference
|
||||
// (a file path, a PKCS#11 URI, a cloud KMS key ID, etc.) and how to
|
||||
// generate a fresh key with a given algorithm.
|
||||
//
|
||||
// Drivers are responsible for any side-effect storage: FileDriver writes
|
||||
// generated keys to disk via the keystore.ensureKeyDirSecure +
|
||||
// keymem.marshalPrivateKeyAndZeroize discipline (injected via the
|
||||
// FileDriver's hooks); future PKCS11Driver delegates key generation to
|
||||
// the token; cloud-KMS drivers call the provider API.
|
||||
//
|
||||
// All Driver methods take a context.Context for cancellation/deadline
|
||||
// propagation. Drivers MUST honor ctx.Done() for any I/O they perform;
|
||||
// purely-in-memory drivers (MemoryDriver) may return immediately
|
||||
// regardless of ctx state.
|
||||
//
|
||||
// Adding a new driver does NOT require changing this interface or any
|
||||
// existing driver. The driver lives in its own package
|
||||
// (internal/crypto/signer/<name>) and is constructed by a typed
|
||||
// factory (e.g., pkcs11.New(config)).
|
||||
type Driver interface {
|
||||
// Load resolves an existing key from ref and returns a Signer.
|
||||
// ref interpretation is driver-specific:
|
||||
//
|
||||
// - FileDriver: filesystem path to a PEM-encoded private key
|
||||
// - PKCS11Driver (future): pkcs11: URI per RFC 7512
|
||||
// - CloudKMSDriver (future): provider-specific resource name
|
||||
//
|
||||
// Drivers MUST NOT log the contents of the loaded key (only the
|
||||
// ref + Algorithm). Callers wrap the returned Signer's Sign method
|
||||
// in their own logging if they need per-signature audit trail.
|
||||
Load(ctx context.Context, ref string) (Signer, error)
|
||||
|
||||
// Generate creates a new key with the given algorithm and persists
|
||||
// it to driver-specific storage (or in-memory for MemoryDriver).
|
||||
// Returns a Signer wrapping the new key plus a ref string the
|
||||
// caller passes to a subsequent Load call (e.g., the file path
|
||||
// for FileDriver, the PKCS#11 URI for PKCS11Driver).
|
||||
//
|
||||
// If alg is not in the supported enum, Generate returns
|
||||
// ErrUnsupportedAlgorithm without side effects (no file written,
|
||||
// no token slot consumed).
|
||||
Generate(ctx context.Context, alg Algorithm) (Signer, string, error)
|
||||
|
||||
// Name returns a stable identifier for the driver type. Used in
|
||||
// structured logs and (eventually) in CRL distribution-point URLs
|
||||
// when the URL embeds the signer kind. MUST be a single
|
||||
// lowercase token without spaces ("file", "memory", "pkcs11",
|
||||
// "aws-kms", "gcp-kms", "azure-kv").
|
||||
Name() string
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package signer_test
|
||||
|
||||
// Behavior-equivalence test suite for the Signer abstraction.
|
||||
//
|
||||
// Phase 2's exit criteria assert that existing tests in the local issuer
|
||||
// pass after the refactor. That's necessary but not sufficient: existing
|
||||
// tests cover specific scenarios and may not catch a subtle byte-level
|
||||
// divergence (e.g., the wrapped Signer marshaling the public key in a
|
||||
// different DER ordering, or producing a slightly different signature
|
||||
// padding). This file is the explicit guard against that class of
|
||||
// regression.
|
||||
//
|
||||
// Three signing surfaces are exercised, mirroring the four call sites in
|
||||
// internal/connector/issuer/local/local.go:
|
||||
// - leaf certificate signing (mirrors local.go::generateCertificate / line ~613)
|
||||
// - CRL signing (mirrors local.go::GenerateCRL / line ~849)
|
||||
// - OCSP response signing (mirrors local.go::SignOCSPResponse / line ~887)
|
||||
// The CA-bootstrap call (line ~482) is implicitly covered by leaf
|
||||
// signing — it's the same x509.CreateCertificate API.
|
||||
//
|
||||
// For each surface, two signatures are compared:
|
||||
// - RSA-2048 / SHA-256: byte-strict equality (PKCS#1 v1.5 is
|
||||
// deterministic given key + digest, so wrapped vs. raw produces
|
||||
// identical full DER bytes).
|
||||
// - ECDSA-P256 / SHA-256: structural equality (ECDSA uses random k
|
||||
// per signature, so signature bytes differ; TBSCertificate /
|
||||
// TBSCertificateList / TBSResponseData bytes — everything signed —
|
||||
// must be byte-equal across raw and wrapped).
|
||||
//
|
||||
// A negative test (TestEquivalence_Sentinel) proves the equivalence
|
||||
// checker would actually catch a regression — without it, a vacuously-
|
||||
// passing assertion would let real divergence through.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ocsp"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
||||
)
|
||||
|
||||
// fixedTemplate returns an x509 cert template with deterministic fields
|
||||
// (no time.Now, no random serial) so two calls to CreateCertificate
|
||||
// produce TBSCertificate bytes that are byte-equal modulo the signature.
|
||||
func fixedTemplate(t *testing.T) (*x509.Certificate, *x509.Certificate) {
|
||||
t.Helper()
|
||||
notBefore := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour)
|
||||
|
||||
caTpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(0xCAFE),
|
||||
Subject: pkix.Name{CommonName: "Equiv CA"},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter.Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
leafTpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(0xC0FFEE),
|
||||
Subject: pkix.Name{CommonName: "leaf.example.com"},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
return caTpl, leafTpl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaf certificate signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEquivalence_RSA_LeafCert_BytesIdentical(t *testing.T) {
|
||||
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa keygen: %v", err)
|
||||
}
|
||||
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("leaf rsa keygen: %v", err)
|
||||
}
|
||||
wrapped, err := signer.Wrap(caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
caTpl, leafTpl := fixedTemplate(t)
|
||||
|
||||
// Self-sign the CA so we have a parsed *x509.Certificate to use as
|
||||
// the leaf cert's parent (CreateCertificate needs both template and
|
||||
// parent; using the same template for both produces a self-signed
|
||||
// CA cert that we then parse).
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CA: %v", err)
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(caDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA: %v", err)
|
||||
}
|
||||
|
||||
// Sign the same leaf cert twice — once via raw caKey, once via
|
||||
// wrapped Signer. PKCS#1 v1.5 is deterministic, so the full DER
|
||||
// must be byte-identical.
|
||||
der1, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf (raw): %v", err)
|
||||
}
|
||||
der2, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf (wrapped): %v", err)
|
||||
}
|
||||
if !bytes.Equal(der1, der2) {
|
||||
t.Fatalf("RSA leaf cert DER differs between raw and wrapped signer:\n raw: %x\n wrapped: %x", der1, der2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquivalence_ECDSA_LeafCert_TBSIdentical(t *testing.T) {
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa keygen: %v", err)
|
||||
}
|
||||
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("leaf ecdsa keygen: %v", err)
|
||||
}
|
||||
wrapped, err := signer.Wrap(caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
caTpl, leafTpl := fixedTemplate(t)
|
||||
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CA: %v", err)
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(caDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA: %v", err)
|
||||
}
|
||||
|
||||
der1, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf (raw): %v", err)
|
||||
}
|
||||
der2, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf (wrapped): %v", err)
|
||||
}
|
||||
|
||||
cert1, err := x509.ParseCertificate(der1)
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf (raw): %v", err)
|
||||
}
|
||||
cert2, err := x509.ParseCertificate(der2)
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf (wrapped): %v", err)
|
||||
}
|
||||
|
||||
// TBSCertificate is everything that gets signed — Subject, Issuer,
|
||||
// Validity, SubjectPublicKeyInfo, Extensions, etc. The signature
|
||||
// bytes themselves differ (ECDSA random k) but the input to the
|
||||
// signature MUST be byte-identical or the wrapper is doing
|
||||
// something behavioral-different than the raw key.
|
||||
if !bytes.Equal(cert1.RawTBSCertificate, cert2.RawTBSCertificate) {
|
||||
t.Fatalf("ECDSA leaf cert TBSCertificate differs between raw and wrapped signer (expected: signature bytes differ; everything else byte-equal)")
|
||||
}
|
||||
|
||||
// Confirm both signatures are independently valid against the CA's
|
||||
// public key. This is the proof that the wrapper actually signed
|
||||
// (not just produced random bytes that happened to match length).
|
||||
if err := cert1.CheckSignatureFrom(caCert); err != nil {
|
||||
t.Fatalf("raw-signed leaf failed validation: %v", err)
|
||||
}
|
||||
if err := cert2.CheckSignatureFrom(caCert); err != nil {
|
||||
t.Fatalf("wrapped-signed leaf failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRL signing (mirrors internal/connector/issuer/local/local.go::GenerateCRL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEquivalence_RSA_CRL_BytesIdentical(t *testing.T) {
|
||||
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
wrapped, err := signer.Wrap(caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
caTpl, _ := fixedTemplate(t)
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
|
||||
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
||||
crlTpl := &x509.RevocationList{
|
||||
Number: big.NewInt(1),
|
||||
ThisUpdate: thisUpdate,
|
||||
NextUpdate: thisUpdate.Add(7 * 24 * time.Hour),
|
||||
RevokedCertificateEntries: []x509.RevocationListEntry{
|
||||
{
|
||||
SerialNumber: big.NewInt(0xDEAD),
|
||||
RevocationTime: thisUpdate,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
der1, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CRL (raw): %v", err)
|
||||
}
|
||||
der2, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("create CRL (wrapped): %v", err)
|
||||
}
|
||||
if !bytes.Equal(der1, der2) {
|
||||
t.Fatalf("RSA CRL DER differs between raw and wrapped signer:\n raw: %x\n wrapped: %x", der1[:64], der2[:64])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquivalence_ECDSA_CRL_TBSIdentical(t *testing.T) {
|
||||
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
wrapped, err := signer.Wrap(caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
caTpl, _ := fixedTemplate(t)
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
|
||||
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
||||
crlTpl := &x509.RevocationList{
|
||||
Number: big.NewInt(1),
|
||||
ThisUpdate: thisUpdate,
|
||||
NextUpdate: thisUpdate.Add(7 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
der1, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CRL (raw): %v", err)
|
||||
}
|
||||
der2, err := x509.CreateRevocationList(rand.Reader, crlTpl, caCert, wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("create CRL (wrapped): %v", err)
|
||||
}
|
||||
|
||||
crl1, err := x509.ParseRevocationList(der1)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CRL (raw): %v", err)
|
||||
}
|
||||
crl2, err := x509.ParseRevocationList(der2)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CRL (wrapped): %v", err)
|
||||
}
|
||||
|
||||
// RawTBSRevocationList is the signed input. Must be byte-equal for
|
||||
// equivalence; signature bytes differ for ECDSA.
|
||||
if !bytes.Equal(crl1.RawTBSRevocationList, crl2.RawTBSRevocationList) {
|
||||
t.Fatalf("ECDSA CRL TBSRevocationList differs between raw and wrapped signer")
|
||||
}
|
||||
|
||||
// Both CRLs must validate against the CA.
|
||||
if err := crl1.CheckSignatureFrom(caCert); err != nil {
|
||||
t.Fatalf("raw-signed CRL failed validation: %v", err)
|
||||
}
|
||||
if err := crl2.CheckSignatureFrom(caCert); err != nil {
|
||||
t.Fatalf("wrapped-signed CRL failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OCSP response signing
|
||||
// (mirrors internal/connector/issuer/local/local.go::SignOCSPResponse)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEquivalence_RSA_OCSPResponse_BytesIdentical(t *testing.T) {
|
||||
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
wrapped, err := signer.Wrap(caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
caTpl, _ := fixedTemplate(t)
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
|
||||
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
||||
ocspTpl := ocsp.Response{
|
||||
Status: ocsp.Good,
|
||||
SerialNumber: big.NewInt(0xCAFEBABE),
|
||||
ThisUpdate: thisUpdate,
|
||||
NextUpdate: thisUpdate.Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp1, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create OCSP (raw): %v", err)
|
||||
}
|
||||
resp2, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("create OCSP (wrapped): %v", err)
|
||||
}
|
||||
if !bytes.Equal(resp1, resp2) {
|
||||
t.Fatalf("RSA OCSP response differs between raw and wrapped signer (PKCS#1 v1.5 must be deterministic)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquivalence_ECDSA_OCSPResponse_StructurallyIdentical(t *testing.T) {
|
||||
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
wrapped, err := signer.Wrap(caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
caTpl, _ := fixedTemplate(t)
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
|
||||
thisUpdate := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
|
||||
ocspTpl := ocsp.Response{
|
||||
Status: ocsp.Good,
|
||||
SerialNumber: big.NewInt(0xCAFEBABE),
|
||||
ThisUpdate: thisUpdate,
|
||||
NextUpdate: thisUpdate.Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp1, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create OCSP (raw): %v", err)
|
||||
}
|
||||
resp2, err := ocsp.CreateResponse(caCert, caCert, ocspTpl, wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("create OCSP (wrapped): %v", err)
|
||||
}
|
||||
|
||||
parsed1, err := ocsp.ParseResponse(resp1, caCert)
|
||||
if err != nil {
|
||||
t.Fatalf("parse OCSP (raw): %v", err)
|
||||
}
|
||||
parsed2, err := ocsp.ParseResponse(resp2, caCert)
|
||||
if err != nil {
|
||||
t.Fatalf("parse OCSP (wrapped): %v", err)
|
||||
}
|
||||
|
||||
// Compare every field except Signature + RawResponderName (which
|
||||
// the parser may normalize differently across calls).
|
||||
if parsed1.Status != parsed2.Status {
|
||||
t.Fatalf("status differs: %d vs %d", parsed1.Status, parsed2.Status)
|
||||
}
|
||||
if parsed1.SerialNumber.Cmp(parsed2.SerialNumber) != 0 {
|
||||
t.Fatalf("serial differs: %v vs %v", parsed1.SerialNumber, parsed2.SerialNumber)
|
||||
}
|
||||
if !parsed1.ThisUpdate.Equal(parsed2.ThisUpdate) {
|
||||
t.Fatalf("ThisUpdate differs")
|
||||
}
|
||||
if !parsed1.NextUpdate.Equal(parsed2.NextUpdate) {
|
||||
t.Fatalf("NextUpdate differs")
|
||||
}
|
||||
|
||||
// Both responses must validate against the CA.
|
||||
if err := parsed1.CheckSignatureFrom(caCert); err != nil {
|
||||
t.Fatalf("raw-signed OCSP failed validation: %v", err)
|
||||
}
|
||||
if err := parsed2.CheckSignatureFrom(caCert); err != nil {
|
||||
t.Fatalf("wrapped-signed OCSP failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Negative test: the equivalence checker isn't trivially-passing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestEquivalence_Sentinel_DifferentKeysProduceDifferentBytes is the smoke
|
||||
// check that the equivalence assertions above would actually catch a
|
||||
// regression. Sign with two different keys; assert the resulting cert
|
||||
// DER bytes differ. If THIS test passes trivially (false negative), the
|
||||
// equivalence checker is broken and the test suite above is not actually
|
||||
// guarding anything.
|
||||
func TestEquivalence_Sentinel_DifferentKeysProduceDifferentBytes(t *testing.T) {
|
||||
keyA, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
keyB, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
caTpl, leafTpl := fixedTemplate(t)
|
||||
leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
|
||||
caDERA, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &keyA.PublicKey, keyA)
|
||||
caCertA, _ := x509.ParseCertificate(caDERA)
|
||||
caDERB, _ := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &keyB.PublicKey, keyB)
|
||||
caCertB, _ := x509.ParseCertificate(caDERB)
|
||||
|
||||
der1, _ := x509.CreateCertificate(rand.Reader, leafTpl, caCertA, &leafKey.PublicKey, keyA)
|
||||
der2, _ := x509.CreateCertificate(rand.Reader, leafTpl, caCertB, &leafKey.PublicKey, keyB)
|
||||
if bytes.Equal(der1, der2) {
|
||||
t.Fatal("sentinel: certs signed by DIFFERENT keys must NOT byte-equal — equivalence checker is trivially-passing")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanity: the wrapped signer's Sign output is independently valid for
|
||||
// arbitrary digests (covers the path that doesn't go through x509.*).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEquivalence_WrappedSign_RSA_VerifiesAgainstStdlib(t *testing.T) {
|
||||
k, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
w, err := signer.Wrap(k)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
digest := sha256OfBytes([]byte("test message"))
|
||||
sig, err := w.Sign(rand.Reader, digest, crypto.SHA256)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if err := rsa.VerifyPKCS1v15(&k.PublicKey, crypto.SHA256, digest, sig); err != nil {
|
||||
t.Fatalf("wrapped RSA Sign produced signature that does not verify with stdlib VerifyPKCS1v15: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquivalence_WrappedSign_ECDSA_VerifiesAgainstStdlib(t *testing.T) {
|
||||
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
w, err := signer.Wrap(k)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
digest := sha256OfBytes([]byte("test message"))
|
||||
sig, err := w.Sign(rand.Reader, digest, crypto.SHA256)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if !ecdsa.VerifyASN1(&k.PublicKey, digest, sig) {
|
||||
t.Fatal("wrapped ECDSA Sign produced signature that does not verify with stdlib VerifyASN1")
|
||||
}
|
||||
}
|
||||
|
||||
func sha256OfBytes(b []byte) []byte {
|
||||
h := sha256.Sum256(b)
|
||||
return h[:]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package signer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemoryDriver holds keys in process memory. It is intended for tests
|
||||
// that need a Signer-shaped object without touching the filesystem
|
||||
// or any external infrastructure. It is NOT for production use:
|
||||
// keys disappear when the process exits, no hardening of any kind is
|
||||
// applied, and concurrent Generate calls have no rate limit.
|
||||
//
|
||||
// The driver is safe for concurrent use; an internal mutex guards the
|
||||
// keys map.
|
||||
type MemoryDriver struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]crypto.Signer
|
||||
// nextID is incremented on every successful Generate; the returned
|
||||
// ref string is "mem-<nextID>" so multiple Generates produce
|
||||
// distinct refs even when callers don't supply one.
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewMemoryDriver returns a freshly initialized MemoryDriver. Callers
|
||||
// holding multiple drivers can rely on each one being independent —
|
||||
// keys from driver A are not visible to driver B.
|
||||
func NewMemoryDriver() *MemoryDriver {
|
||||
return &MemoryDriver{keys: map[string]crypto.Signer{}}
|
||||
}
|
||||
|
||||
// Name implements Driver.
|
||||
func (d *MemoryDriver) Name() string { return "memory" }
|
||||
|
||||
// Load implements Driver. Returns the Signer for the given ref, or an
|
||||
// error if the ref was never produced by Generate / Adopt.
|
||||
func (d *MemoryDriver) Load(ctx context.Context, ref string) (Signer, error) {
|
||||
if ref == "" {
|
||||
return nil, errors.New("signer.MemoryDriver.Load: empty ref")
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
key, ok := d.keys[ref]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("signer.MemoryDriver.Load: unknown ref %q", ref)
|
||||
}
|
||||
return Wrap(key)
|
||||
}
|
||||
|
||||
// Generate implements Driver. Creates a fresh in-memory key with the
|
||||
// requested algorithm and returns the wrapped Signer plus the ref
|
||||
// string callers can pass to a subsequent Load.
|
||||
func (d *MemoryDriver) Generate(ctx context.Context, alg Algorithm) (Signer, string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: %w", err)
|
||||
}
|
||||
|
||||
var key crypto.Signer
|
||||
switch alg {
|
||||
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
|
||||
bits := rsaBitsFor(alg)
|
||||
k, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: rsa keygen %d: %w", bits, err)
|
||||
}
|
||||
key = k
|
||||
case AlgorithmECDSAP256, AlgorithmECDSAP384:
|
||||
curve := ecCurveFor(alg)
|
||||
k, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: ecdsa keygen %s: %w", curve.Params().Name, err)
|
||||
}
|
||||
key = k
|
||||
default:
|
||||
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: %w: %s", ErrUnsupportedAlgorithm, alg)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.nextID++
|
||||
ref := fmt.Sprintf("mem-%d", d.nextID)
|
||||
d.keys[ref] = key
|
||||
d.mu.Unlock()
|
||||
|
||||
wrapped, err := Wrap(key)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.MemoryDriver.Generate: wrap: %w", err)
|
||||
}
|
||||
return wrapped, ref, nil
|
||||
}
|
||||
|
||||
// Adopt registers an externally-generated crypto.Signer under ref so
|
||||
// subsequent Load calls return it. Returns an error if ref is already
|
||||
// taken — keep refs unique to avoid silent override surprises.
|
||||
//
|
||||
// Useful in tests that want a deterministic key (generated outside
|
||||
// the driver, e.g. from a fixed PEM fixture) reachable through the
|
||||
// driver.
|
||||
func (d *MemoryDriver) Adopt(ref string, key crypto.Signer) error {
|
||||
if ref == "" {
|
||||
return errors.New("signer.MemoryDriver.Adopt: empty ref")
|
||||
}
|
||||
if key == nil {
|
||||
return errors.New("signer.MemoryDriver.Adopt: nil key")
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if _, exists := d.keys[ref]; exists {
|
||||
return fmt.Errorf("signer.MemoryDriver.Adopt: ref %q already exists", ref)
|
||||
}
|
||||
d.keys[ref] = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// _ guards that MemoryDriver implements Driver (catch interface drift
|
||||
// at build time, not test time).
|
||||
var _ Driver = (*MemoryDriver)(nil)
|
||||
|
||||
// _ guards that FileDriver implements Driver.
|
||||
var _ Driver = (*FileDriver)(nil)
|
||||
@@ -0,0 +1,68 @@
|
||||
package signer
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
"crypto/x509"
|
||||
)
|
||||
|
||||
// parsePrivateKey parses a PEM block into a crypto.Signer. Recognises the
|
||||
// three PEM block types historically produced and consumed by certctl's
|
||||
// local CA:
|
||||
//
|
||||
// - "RSA PRIVATE KEY" (PKCS#1 / RFC 3447, openssl genrsa default)
|
||||
// - "EC PRIVATE KEY" (SEC 1 / RFC 5915, openssl ecparam default)
|
||||
// - "PRIVATE KEY" (PKCS#8 / RFC 5208 — wraps RSA, ECDSA, others)
|
||||
//
|
||||
// This function is the single source of truth for PEM private-key parsing
|
||||
// inside certctl. It was moved here from
|
||||
// internal/connector/issuer/local/local.go as part of the Signer
|
||||
// abstraction work; the local package now calls into here. Do not
|
||||
// reintroduce a parallel implementation elsewhere.
|
||||
//
|
||||
// Behavior preserved exactly across the move:
|
||||
// - Block type matching is case-sensitive (PEM convention).
|
||||
// - PKCS#8 blocks that contain a non-Signer key (e.g., a Diffie-Hellman
|
||||
// key, an Ed25519 key absent stdlib Signer support) return an error
|
||||
// rather than a panic.
|
||||
// - The error wrapping format is intentionally stable so existing test
|
||||
// assertions in internal/connector/issuer/local/local_test.go and
|
||||
// bundle9_coverage_test.go continue to match without modification.
|
||||
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 is the exported wrapper used by callers outside this
|
||||
// package. It exists so that internal/connector/issuer/local/ (and any
|
||||
// future caller that needs to load a PEM private key without going
|
||||
// through a Driver — e.g., a one-off tool, a migration helper) can
|
||||
// share the parser without re-implementing the block-type dispatch.
|
||||
//
|
||||
// Most callers should use a Driver instead — Driver.Load handles the
|
||||
// file-read + PEM decode + key parse + Signer wrap in one call.
|
||||
// ParsePrivateKey is exposed for the corner cases where a caller
|
||||
// already holds the *pem.Block (e.g., the block was extracted from a
|
||||
// multi-block PEM bundle).
|
||||
func ParsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
return parsePrivateKey(block)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package signer
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Signer extends crypto.Signer with an Algorithm method that lets callers
|
||||
// pick the matching x509.SignatureAlgorithm without reflecting on the key.
|
||||
//
|
||||
// Implementations MUST satisfy the crypto.Signer contract: Public() returns
|
||||
// the matching public key, and Sign(rand, digest, opts) produces a
|
||||
// signature in the algorithm's standard wire format (PKCS#1 v1.5 / PSS for
|
||||
// RSA, ASN.1 DER-encoded ECDSA-Sig-Value for ECDSA). The Algorithm method
|
||||
// is purely a metadata accessor — it MUST NOT cause I/O.
|
||||
type Signer interface {
|
||||
crypto.Signer
|
||||
Algorithm() Algorithm
|
||||
}
|
||||
|
||||
// Algorithm enumerates the certctl-supported signing algorithms.
|
||||
//
|
||||
// The set is deliberately small. Adding an algorithm requires updating
|
||||
// signer.go's enum, parse.go's algorithmFromKey, the SignatureAlgorithm
|
||||
// helper below, and the corresponding profile validators in
|
||||
// internal/service that gate operator-facing key-policy choices. Do not
|
||||
// add Ed25519 (or any new algorithm) without that full sweep — the
|
||||
// half-implemented case is worse than the absent case.
|
||||
type Algorithm string
|
||||
|
||||
// Algorithm constants enumerate the certctl-supported signing algorithms.
|
||||
// Wire-format strings match the operator-facing values used in
|
||||
// CertificateProfile validators so the values are stable across the
|
||||
// audit/policy/connector boundary.
|
||||
const (
|
||||
// AlgorithmRSA2048 is RSA with a 2048-bit modulus.
|
||||
AlgorithmRSA2048 Algorithm = "RSA-2048"
|
||||
// AlgorithmRSA3072 is RSA with a 3072-bit modulus.
|
||||
AlgorithmRSA3072 Algorithm = "RSA-3072"
|
||||
// AlgorithmRSA4096 is RSA with a 4096-bit modulus.
|
||||
AlgorithmRSA4096 Algorithm = "RSA-4096"
|
||||
// AlgorithmECDSAP256 is ECDSA over the NIST P-256 (secp256r1) curve.
|
||||
AlgorithmECDSAP256 Algorithm = "ECDSA-P256"
|
||||
// AlgorithmECDSAP384 is ECDSA over the NIST P-384 (secp384r1) curve.
|
||||
AlgorithmECDSAP384 Algorithm = "ECDSA-P384"
|
||||
)
|
||||
|
||||
// ErrUnsupportedAlgorithm is returned when a key uses a curve, modulus,
|
||||
// or type the signer package does not recognize. Callers can use
|
||||
// errors.Is to distinguish this from other failure modes.
|
||||
var ErrUnsupportedAlgorithm = errors.New("signer: unsupported key algorithm")
|
||||
|
||||
// SignatureAlgorithm maps a Signer's Algorithm to the matching
|
||||
// x509.SignatureAlgorithm. Used by call sites that build cert / CRL /
|
||||
// OCSP templates so they don't have to do their own type-switch.
|
||||
//
|
||||
// Returns x509.UnknownSignatureAlgorithm for unrecognized inputs;
|
||||
// callers SHOULD treat that as a bug (the only supported values are the
|
||||
// constants above).
|
||||
func SignatureAlgorithm(a Algorithm) x509.SignatureAlgorithm {
|
||||
switch a {
|
||||
case AlgorithmRSA2048, AlgorithmRSA3072, AlgorithmRSA4096:
|
||||
return x509.SHA256WithRSA
|
||||
case AlgorithmECDSAP256:
|
||||
return x509.ECDSAWithSHA256
|
||||
case AlgorithmECDSAP384:
|
||||
return x509.ECDSAWithSHA384
|
||||
default:
|
||||
return x509.UnknownSignatureAlgorithm
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap adapts a stdlib crypto.Signer into a signer.Signer by inferring
|
||||
// the Algorithm from the key's public half. Returns ErrUnsupportedAlgorithm
|
||||
// (wrapped with key-shape detail) for keys outside the supported enum.
|
||||
//
|
||||
// This is the canonical adapter used by every Driver in this package
|
||||
// and by callers that already hold a crypto.Signer (e.g., a key parsed
|
||||
// elsewhere). Drivers SHOULD NOT implement Signer from scratch; wrapping
|
||||
// keeps the Algorithm-detection logic in one place.
|
||||
func Wrap(s crypto.Signer) (Signer, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("signer.Wrap: nil signer")
|
||||
}
|
||||
alg, err := algorithmFromKey(s.Public())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrappedSigner{inner: s, alg: alg}, nil
|
||||
}
|
||||
|
||||
// wrappedSigner is the concrete type returned by Wrap. It is unexported
|
||||
// so the only path to a Signer is through Wrap (or a Driver that calls
|
||||
// Wrap internally) — that keeps Algorithm()'s value-semantics consistent.
|
||||
type wrappedSigner struct {
|
||||
inner crypto.Signer
|
||||
alg Algorithm
|
||||
}
|
||||
|
||||
func (w *wrappedSigner) Public() crypto.PublicKey { return w.inner.Public() }
|
||||
|
||||
func (w *wrappedSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
return w.inner.Sign(rand, digest, opts)
|
||||
}
|
||||
|
||||
func (w *wrappedSigner) Algorithm() Algorithm { return w.alg }
|
||||
|
||||
// algorithmFromKey infers the Algorithm enum value from a public key.
|
||||
// Used by Wrap; exported via the Signer contract through Algorithm().
|
||||
//
|
||||
// Bounds-checked against the enum exactly: an RSA-1024 key returns
|
||||
// ErrUnsupportedAlgorithm even though it would otherwise satisfy
|
||||
// crypto.Signer — the local CA never produces RSA-1024 and operators
|
||||
// importing such a key into a sub-CA path should fail loudly at load
|
||||
// time, not at first-sign time.
|
||||
func algorithmFromKey(pub crypto.PublicKey) (Algorithm, error) {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
switch k.N.BitLen() {
|
||||
case 2048:
|
||||
return AlgorithmRSA2048, nil
|
||||
case 3072:
|
||||
return AlgorithmRSA3072, nil
|
||||
case 4096:
|
||||
return AlgorithmRSA4096, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: RSA modulus %d bits (supported: 2048, 3072, 4096)",
|
||||
ErrUnsupportedAlgorithm, k.N.BitLen())
|
||||
}
|
||||
case *ecdsa.PublicKey:
|
||||
switch k.Curve {
|
||||
case elliptic.P256():
|
||||
return AlgorithmECDSAP256, nil
|
||||
case elliptic.P384():
|
||||
return AlgorithmECDSAP384, nil
|
||||
default:
|
||||
name := "unknown"
|
||||
if p := k.Curve.Params(); p != nil {
|
||||
name = p.Name
|
||||
}
|
||||
return "", fmt.Errorf("%w: ECDSA curve %s (supported: P-256, P-384)",
|
||||
ErrUnsupportedAlgorithm, name)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %T (supported: *rsa.PublicKey, *ecdsa.PublicKey)",
|
||||
ErrUnsupportedAlgorithm, pub)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
package signer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm + SignatureAlgorithm mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSignatureAlgorithm_Mapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
alg signer.Algorithm
|
||||
want x509.SignatureAlgorithm
|
||||
}{
|
||||
{signer.AlgorithmRSA2048, x509.SHA256WithRSA},
|
||||
{signer.AlgorithmRSA3072, x509.SHA256WithRSA},
|
||||
{signer.AlgorithmRSA4096, x509.SHA256WithRSA},
|
||||
{signer.AlgorithmECDSAP256, x509.ECDSAWithSHA256},
|
||||
{signer.AlgorithmECDSAP384, x509.ECDSAWithSHA384},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(string(tc.alg), func(t *testing.T) {
|
||||
if got := signer.SignatureAlgorithm(tc.alg); got != tc.want {
|
||||
t.Fatalf("SignatureAlgorithm(%q) = %v, want %v", tc.alg, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Unknown should map to UnknownSignatureAlgorithm.
|
||||
if got := signer.SignatureAlgorithm(signer.Algorithm("bogus")); got != x509.UnknownSignatureAlgorithm {
|
||||
t.Fatalf("unknown algorithm should map to UnknownSignatureAlgorithm, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wrap / algorithmFromKey: every supported key shape + several rejected ones
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrap_RSA_AllSupportedSizes(t *testing.T) {
|
||||
cases := []struct {
|
||||
bits int
|
||||
want signer.Algorithm
|
||||
}{
|
||||
{2048, signer.AlgorithmRSA2048},
|
||||
{3072, signer.AlgorithmRSA3072},
|
||||
// 4096 omitted: too slow for short tests; covered indirectly via Generate
|
||||
}
|
||||
for _, tc := range cases {
|
||||
k, err := rsa.GenerateKey(rand.Reader, tc.bits)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey(%d): %v", tc.bits, err)
|
||||
}
|
||||
s, err := signer.Wrap(k)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap RSA-%d: %v", tc.bits, err)
|
||||
}
|
||||
if got := s.Algorithm(); got != tc.want {
|
||||
t.Fatalf("RSA-%d Algorithm = %q, want %q", tc.bits, got, tc.want)
|
||||
}
|
||||
if s.Public() == nil {
|
||||
t.Fatalf("RSA-%d Public() returned nil", tc.bits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_ECDSA_AllSupportedCurves(t *testing.T) {
|
||||
cases := []struct {
|
||||
curve elliptic.Curve
|
||||
want signer.Algorithm
|
||||
}{
|
||||
{elliptic.P256(), signer.AlgorithmECDSAP256},
|
||||
{elliptic.P384(), signer.AlgorithmECDSAP384},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
k, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey(%s): %v", tc.curve.Params().Name, err)
|
||||
}
|
||||
s, err := signer.Wrap(k)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap %s: %v", tc.curve.Params().Name, err)
|
||||
}
|
||||
if got := s.Algorithm(); got != tc.want {
|
||||
t.Fatalf("%s Algorithm = %q, want %q", tc.curve.Params().Name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_RejectsNilSigner(t *testing.T) {
|
||||
_, err := signer.Wrap(nil)
|
||||
if err == nil {
|
||||
t.Fatal("Wrap(nil) should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_RejectsRSA1024(t *testing.T) {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey(1024): %v", err)
|
||||
}
|
||||
_, err = signer.Wrap(k)
|
||||
if err == nil {
|
||||
t.Fatal("Wrap RSA-1024 should error")
|
||||
}
|
||||
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
|
||||
t.Fatalf("Wrap RSA-1024 should wrap ErrUnsupportedAlgorithm, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_RejectsECDSAP224(t *testing.T) {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey(P-224): %v", err)
|
||||
}
|
||||
_, err = signer.Wrap(k)
|
||||
if err == nil {
|
||||
t.Fatal("Wrap ECDSA P-224 should error")
|
||||
}
|
||||
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
|
||||
t.Fatalf("Wrap ECDSA P-224 should wrap ErrUnsupportedAlgorithm, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_RejectsEd25519(t *testing.T) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||
}
|
||||
_, err = signer.Wrap(priv)
|
||||
if err == nil {
|
||||
t.Fatal("Wrap Ed25519 should error (not in supported enum)")
|
||||
}
|
||||
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
|
||||
t.Fatalf("Wrap Ed25519 should wrap ErrUnsupportedAlgorithm, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_PreservesSignBehavior(t *testing.T) {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
s, err := signer.Wrap(k)
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte("hello world"))
|
||||
sig, err := s.Sign(rand.Reader, digest[:], crypto.SHA256)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if !ecdsa.VerifyASN1(&k.PublicKey, digest[:], sig) {
|
||||
t.Fatal("Wrap'd signer produced signature that does not verify")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePrivateKey via the exported ParsePrivateKey: all three PEM block types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParsePrivateKey_PKCS1_RSA(t *testing.T) {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
|
||||
got, err := signer.ParsePrivateKey(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if _, ok := got.(*rsa.PrivateKey); !ok {
|
||||
t.Fatalf("ParsePrivateKey returned %T, want *rsa.PrivateKey", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_SEC1_ECDSA(t *testing.T) {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalECPrivateKey: %v", err)
|
||||
}
|
||||
block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}
|
||||
got, err := signer.ParsePrivateKey(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if _, ok := got.(*ecdsa.PrivateKey); !ok {
|
||||
t.Fatalf("ParsePrivateKey returned %T, want *ecdsa.PrivateKey", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8_RSA(t *testing.T) {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
|
||||
got, err := signer.ParsePrivateKey(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if _, ok := got.(*rsa.PrivateKey); !ok {
|
||||
t.Fatalf("ParsePrivateKey returned %T, want *rsa.PrivateKey", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8_ECDSA(t *testing.T) {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
|
||||
got, err := signer.ParsePrivateKey(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if _, ok := got.(*ecdsa.PrivateKey); !ok {
|
||||
t.Fatalf("ParsePrivateKey returned %T, want *ecdsa.PrivateKey", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8_Ed25519_AcceptedByParser(t *testing.T) {
|
||||
// Ed25519 satisfies crypto.Signer, so parsePrivateKey returns it
|
||||
// successfully — Wrap is the layer that rejects it (ErrUnsupportedAlgorithm).
|
||||
// This pin confirms the separation: parsing never silently rejects a
|
||||
// valid PKCS#8 key just because Wrap won't accept it.
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
|
||||
got, err := signer.ParsePrivateKey(block)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if _, ok := got.(ed25519.PrivateKey); !ok {
|
||||
t.Fatalf("ParsePrivateKey returned %T, want ed25519.PrivateKey", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_UnsupportedBlockType(t *testing.T) {
|
||||
block := &pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")}
|
||||
_, err := signer.ParsePrivateKey(block)
|
||||
if err == nil {
|
||||
t.Fatal("ParsePrivateKey on CERTIFICATE block should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported private key type") {
|
||||
t.Fatalf("error should say 'unsupported private key type', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKey_PKCS8_BadBytes(t *testing.T) {
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: []byte("not pkcs8")}
|
||||
_, err := signer.ParsePrivateKey(block)
|
||||
if err == nil {
|
||||
t.Fatal("ParsePrivateKey on garbage PKCS#8 should error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileDriver.Load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writePEMKey(t *testing.T, dir string, blockType string, der []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "key.pem")
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der})
|
||||
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||||
t.Fatalf("write key file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_Roundtrip_RSA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
path := writePEMKey(t, dir, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(k))
|
||||
|
||||
d := &signer.FileDriver{}
|
||||
s, err := d.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FileDriver.Load: %v", err)
|
||||
}
|
||||
if s.Algorithm() != signer.AlgorithmRSA2048 {
|
||||
t.Fatalf("Algorithm = %q, want RSA-2048", s.Algorithm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_Roundtrip_ECDSA_PKCS8(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
path := writePEMKey(t, dir, "PRIVATE KEY", der)
|
||||
|
||||
d := &signer.FileDriver{}
|
||||
s, err := d.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FileDriver.Load: %v", err)
|
||||
}
|
||||
if s.Algorithm() != signer.AlgorithmECDSAP256 {
|
||||
t.Fatalf("Algorithm = %q, want ECDSA-P256", s.Algorithm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_EmptyPath(t *testing.T) {
|
||||
d := &signer.FileDriver{}
|
||||
_, err := d.Load(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("Load(\"\") should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_NonExistentPath(t *testing.T) {
|
||||
d := &signer.FileDriver{}
|
||||
_, err := d.Load(context.Background(), "/no/such/path.pem")
|
||||
if err == nil {
|
||||
t.Fatal("Load(non-existent) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_NotPEM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "garbage.bin")
|
||||
if err := os.WriteFile(path, []byte("not pem"), 0o600); err != nil {
|
||||
t.Fatalf("write garbage: %v", err)
|
||||
}
|
||||
d := &signer.FileDriver{}
|
||||
_, err := d.Load(context.Background(), path)
|
||||
if err == nil {
|
||||
t.Fatal("Load(non-PEM) should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not PEM") {
|
||||
t.Fatalf("error should say 'is not PEM', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_UnsupportedKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 1024) // unsupported bit size
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
path := writePEMKey(t, dir, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(k))
|
||||
|
||||
d := &signer.FileDriver{}
|
||||
_, err = d.Load(context.Background(), path)
|
||||
if err == nil {
|
||||
t.Fatal("Load RSA-1024 key should error (Wrap rejects)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Load_CtxCancelled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
k, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
path := writePEMKey(t, dir, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(k))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
d := &signer.FileDriver{}
|
||||
_, err := d.Load(ctx, path)
|
||||
if err == nil {
|
||||
t.Fatal("Load with cancelled ctx should error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileDriver.Generate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFileDriver_Generate_RequiresDirHardener(t *testing.T) {
|
||||
d := &signer.FileDriver{} // no DirHardener
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate without DirHardener should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DirHardener is required") {
|
||||
t.Fatalf("error should mention DirHardener, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_AppliesDirHardener(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var calledWith []string
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(d string) error {
|
||||
calledWith = append(calledWith, d)
|
||||
return nil
|
||||
},
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return filepath.Join(dir, "gen.key"), nil
|
||||
},
|
||||
}
|
||||
_, path, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if path != filepath.Join(dir, "gen.key") {
|
||||
t.Fatalf("path = %q, want %q", path, filepath.Join(dir, "gen.key"))
|
||||
}
|
||||
if len(calledWith) != 1 || calledWith[0] != dir {
|
||||
t.Fatalf("DirHardener called with %v, want [%q]", calledWith, dir)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("generated key file should exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_DirHardenerErrorPropagates(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(_ string) error { return errors.New("simulated harden failure") },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return "/tmp/should-not-be-written.key", nil
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate should fail when DirHardener returns error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "simulated harden failure") {
|
||||
t.Fatalf("error should propagate harden failure, got %q", err.Error())
|
||||
}
|
||||
if _, err := os.Stat("/tmp/should-not-be-written.key"); err == nil {
|
||||
t.Fatal("file should NOT have been written when harden failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_AppliesECMarshaler(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var marshalerCalled bool
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return filepath.Join(dir, "gen.key"), nil
|
||||
},
|
||||
Marshaler: func(k *ecdsa.PrivateKey) ([]byte, error) {
|
||||
marshalerCalled = true
|
||||
der, err := x509.MarshalECPrivateKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !marshalerCalled {
|
||||
t.Fatal("Marshaler should have been called for ECDSA Generate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_AppliesRSAMarshaler(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var rsaCalled bool
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return filepath.Join(dir, "gen.key"), nil
|
||||
},
|
||||
RSAMarshaler: func(k *rsa.PrivateKey) ([]byte, error) {
|
||||
rsaCalled = true
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmRSA2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !rsaCalled {
|
||||
t.Fatal("RSAMarshaler should have been called for RSA Generate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_DefaultMarshalers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
GenerateOutPath: func(a signer.Algorithm) (string, error) {
|
||||
return filepath.Join(dir, string(a)+".key"), nil
|
||||
},
|
||||
}
|
||||
for _, alg := range []signer.Algorithm{signer.AlgorithmRSA2048, signer.AlgorithmECDSAP256} {
|
||||
s, path, err := d.Generate(context.Background(), alg)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", alg, err)
|
||||
}
|
||||
if s.Algorithm() != alg {
|
||||
t.Fatalf("Algorithm = %q, want %q", s.Algorithm(), alg)
|
||||
}
|
||||
// Round-trip: load via the same driver, verify bytes parse.
|
||||
loaded, err := d.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%s): %v", path, err)
|
||||
}
|
||||
if loaded.Algorithm() != alg {
|
||||
t.Fatalf("Loaded Algorithm = %q, want %q", loaded.Algorithm(), alg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_RejectsUnknownAlgorithm(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.Algorithm("ed25519"))
|
||||
if err == nil {
|
||||
t.Fatal("Generate with unknown algorithm should error")
|
||||
}
|
||||
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
|
||||
t.Fatalf("error should wrap ErrUnsupportedAlgorithm, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_CtxCancelled(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, err := d.Generate(ctx, signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate with cancelled ctx should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_RSAMarshalerError(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) { return "/tmp/x", nil },
|
||||
RSAMarshaler: func(*rsa.PrivateKey) ([]byte, error) { return nil, errors.New("boom") },
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmRSA2048)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected RSAMarshaler error to surface, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_ECMarshalerError(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) { return "/tmp/x", nil },
|
||||
Marshaler: func(*ecdsa.PrivateKey) ([]byte, error) { return nil, errors.New("ec-boom") },
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil || !strings.Contains(err.Error(), "ec-boom") {
|
||||
t.Fatalf("expected Marshaler error to surface, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Generate_OutPathError(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return "", errors.New("path-resolve-failure")
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil || !strings.Contains(err.Error(), "path-resolve-failure") {
|
||||
t.Fatalf("expected GenerateOutPath error to surface, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDriver_Name(t *testing.T) {
|
||||
d := &signer.FileDriver{}
|
||||
if d.Name() != "file" {
|
||||
t.Fatalf("Name = %q, want \"file\"", d.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryDriver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMemoryDriver_Name(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
if d.Name() != "memory" {
|
||||
t.Fatalf("Name = %q, want \"memory\"", d.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_GenerateAndLoad(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
for _, alg := range []signer.Algorithm{
|
||||
signer.AlgorithmRSA2048,
|
||||
signer.AlgorithmECDSAP256,
|
||||
signer.AlgorithmECDSAP384,
|
||||
} {
|
||||
s1, ref, err := d.Generate(context.Background(), alg)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", alg, err)
|
||||
}
|
||||
if s1.Algorithm() != alg {
|
||||
t.Fatalf("Generated Algorithm = %q, want %q", s1.Algorithm(), alg)
|
||||
}
|
||||
s2, err := d.Load(context.Background(), ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%q): %v", ref, err)
|
||||
}
|
||||
if s2.Algorithm() != alg {
|
||||
t.Fatalf("Loaded Algorithm = %q, want %q", s2.Algorithm(), alg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Generate_IndependentRefs(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
_, ref1, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate#1: %v", err)
|
||||
}
|
||||
_, ref2, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate#2: %v", err)
|
||||
}
|
||||
if ref1 == ref2 {
|
||||
t.Fatalf("two Generate calls produced the same ref %q", ref1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Load_EmptyRef(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
_, err := d.Load(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("Load(\"\") should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Load_UnknownRef(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
_, err := d.Load(context.Background(), "mem-9999")
|
||||
if err == nil {
|
||||
t.Fatal("Load(unknown) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Generate_CtxCancelled(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, err := d.Generate(ctx, signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate with cancelled ctx should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Generate_RejectsUnknownAlgorithm(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
_, _, err := d.Generate(context.Background(), signer.Algorithm("nope"))
|
||||
if err == nil {
|
||||
t.Fatal("Generate(unknown alg) should error")
|
||||
}
|
||||
if !errors.Is(err, signer.ErrUnsupportedAlgorithm) {
|
||||
t.Fatalf("expected ErrUnsupportedAlgorithm, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Adopt(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err := d.Adopt("my-test-key", k); err != nil {
|
||||
t.Fatalf("Adopt: %v", err)
|
||||
}
|
||||
s, err := d.Load(context.Background(), "my-test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("Load adopted key: %v", err)
|
||||
}
|
||||
if s.Algorithm() != signer.AlgorithmECDSAP256 {
|
||||
t.Fatalf("Algorithm = %q, want ECDSA-P256", s.Algorithm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Adopt_RejectsEmptyRef(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err := d.Adopt("", k); err == nil {
|
||||
t.Fatal("Adopt(\"\") should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Adopt_RejectsNilKey(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
if err := d.Adopt("ref", nil); err == nil {
|
||||
t.Fatal("Adopt(nil) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDriver_Adopt_RejectsDuplicateRef(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err := d.Adopt("ref", k); err != nil {
|
||||
t.Fatalf("first Adopt: %v", err)
|
||||
}
|
||||
if err := d.Adopt("ref", k); err == nil {
|
||||
t.Fatal("duplicate Adopt should error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-driver behavior pin: Algorithm always matches the public key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSigner_AlgorithmMatchesKey(t *testing.T) {
|
||||
d := signer.NewMemoryDriver()
|
||||
for _, alg := range []signer.Algorithm{
|
||||
signer.AlgorithmRSA2048,
|
||||
signer.AlgorithmECDSAP256,
|
||||
signer.AlgorithmECDSAP384,
|
||||
} {
|
||||
s, _, err := d.Generate(context.Background(), alg)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", alg, err)
|
||||
}
|
||||
// Re-derive Algorithm from the public key directly and confirm it matches.
|
||||
if alg == signer.AlgorithmRSA2048 {
|
||||
rk, ok := s.Public().(*rsa.PublicKey)
|
||||
if !ok || rk.N.BitLen() != 2048 {
|
||||
t.Fatalf("expected RSA-2048 public key, got %T", s.Public())
|
||||
}
|
||||
}
|
||||
if alg == signer.AlgorithmECDSAP256 {
|
||||
ek, ok := s.Public().(*ecdsa.PublicKey)
|
||||
if !ok || ek.Curve != elliptic.P256() {
|
||||
t.Fatalf("expected ECDSA-P256 public key")
|
||||
}
|
||||
}
|
||||
if alg == signer.AlgorithmECDSAP384 {
|
||||
ek, ok := s.Public().(*ecdsa.PublicKey)
|
||||
if !ok || ek.Curve != elliptic.P384() {
|
||||
t.Fatalf("expected ECDSA-P384 public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user