diff --git a/proxy/ca.go b/proxy/ca.go
@@ -16,6 +16,17 @@ import (
"time"
)
+const (
+ leafLifetime = 24 * time.Hour
+ // Re-issue this long before expiry so a leaf cannot expire mid-handshake.
+ leafRenewMargin = time.Minute
+)
+
+type cachedLeaf struct {
+ cert tls.Certificate
+ notAfter time.Time
+}
+
// LocalCA is a self-signed CA used to issue per-host certificates for
// MitM TLS interception in client-front mode.
type LocalCA struct {
@@ -24,7 +35,7 @@ type LocalCA struct {
derCert []byte // raw DER, appended as chain in issued certs
mu sync.Mutex
- cache map[string]tls.Certificate
+ cache map[string]cachedLeaf
}
func GenerateCA() (*LocalCA, error) {
@@ -37,11 +48,16 @@ func GenerateCA() (*LocalCA, error) {
// LoadOrGenerateCA loads the CA from certPath + keyPath. If the files do not
// exist it generates a new CA and writes it to those paths. Pass empty strings
-// for an ephemeral in-memory CA.
+// for both to get an ephemeral in-memory CA.
func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
- if certPath == "" || keyPath == "" {
+ if certPath == "" && keyPath == "" {
return GenerateCA()
}
+ // A half-specified pair used to silently return an ephemeral CA that was
+ // never written, so callers told users to import a file that never existed.
+ if certPath == "" || keyPath == "" {
+ return nil, fmt.Errorf("CA cert path and key path must both be set or both be empty")
+ }
ca, err := loadCA(certPath, keyPath)
if err == nil {
@@ -78,7 +94,7 @@ func loadCA(certPath, keyPath string) (*LocalCA, error) {
cert: cert,
key: key,
derCert: pair.Certificate[0],
- cache: make(map[string]tls.Certificate),
+ cache: make(map[string]cachedLeaf),
}, nil
}
@@ -108,7 +124,7 @@ func buildCA(key *ecdsa.PrivateKey) (*LocalCA, error) {
cert: cert,
key: key,
derCert: der,
- cache: make(map[string]tls.Certificate),
+ cache: make(map[string]cachedLeaf),
}, nil
}
@@ -126,11 +142,16 @@ func (ca *LocalCA) Save(certPath, keyPath string) error {
if err != nil {
return err
}
- kf, err := os.Create(keyPath)
+ kf, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer kf.Close()
+ // O_CREATE's mode is ignored when the file already exists, and the mode of a
+ // new file is masked by umask, so set it explicitly either way.
+ if err := kf.Chmod(0o600); err != nil {
+ return err
+ }
return pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
}
@@ -140,14 +161,14 @@ func (ca *LocalCA) CertPEM() []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.derCert})
}
-// issueCert returns a leaf certificate for host signed by the CA, issuing and
-// caching a new one on first call.
+// issueCert returns a leaf certificate for host signed by the CA, issuing a new
+// one when the host is unseen or its cached leaf is at or near expiry.
func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
ca.mu.Lock()
defer ca.mu.Unlock()
- if c, ok := ca.cache[host]; ok {
- return c, nil
+ if c, ok := ca.cache[host]; ok && time.Now().Before(c.notAfter.Add(-leafRenewMargin)) {
+ return c.cert, nil
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -158,11 +179,12 @@ func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
if err != nil {
return tls.Certificate{}, err
}
+ notAfter := time.Now().Add(leafLifetime)
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: time.Now().Add(-time.Hour),
- NotAfter: time.Now().Add(24 * time.Hour),
+ NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
@@ -180,6 +202,6 @@ func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
Certificate: [][]byte{der, ca.derCert}, // CA appended so clients can verify the chain
PrivateKey: key,
}
- ca.cache[host] = cert
+ ca.cache[host] = cachedLeaf{cert: cert, notAfter: notAfter}
return cert, nil
}
diff --git a/proxy/ca_test.go b/proxy/ca_test.go
@@ -4,7 +4,10 @@ import (
"bytes"
"crypto/x509"
"net"
+ "os"
+ "path/filepath"
"testing"
+ "time"
)
func TestIssueCert_SignedChain_DNS(t *testing.T) {
@@ -103,3 +106,201 @@ func TestIssueCert_Caches(t *testing.T) {
t.Errorf("cache size = %d; want 2", len(ca.cache))
}
}
+
+// A MitM leaf must never be usable as a CA. This is the property that lets an
+// attacker with the leaf key mint certs for any host, so assert it directly.
+func TestIssueCert_LeafIsNotACA(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+ cert, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert: %v", err)
+ }
+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
+ if err != nil {
+ t.Fatalf("parse leaf: %v", err)
+ }
+
+ if leaf.IsCA {
+ t.Error("leaf has IsCA = true; a MitM leaf must not be a CA")
+ }
+ if leaf.KeyUsage&x509.KeyUsageCertSign != 0 {
+ t.Error("leaf KeyUsage includes CertSign; a MitM leaf must not sign certs")
+ }
+ if leaf.KeyUsage&x509.KeyUsageCRLSign != 0 {
+ t.Error("leaf KeyUsage includes CRLSign")
+ }
+ if leaf.KeyUsage != x509.KeyUsageDigitalSignature {
+ t.Errorf("leaf KeyUsage = %v; want DigitalSignature only", leaf.KeyUsage)
+ }
+ if len(leaf.ExtKeyUsage) != 1 || leaf.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
+ t.Errorf("leaf ExtKeyUsage = %v; want [ServerAuth]", leaf.ExtKeyUsage)
+ }
+}
+
+func TestIssueCert_ReissuesExpiredLeaf(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+ first, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert: %v", err)
+ }
+
+ ca.mu.Lock()
+ entry := ca.cache["example.com"]
+ entry.notAfter = time.Now().Add(-time.Second)
+ ca.cache["example.com"] = entry
+ ca.mu.Unlock()
+
+ second, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert after expiry: %v", err)
+ }
+ if bytes.Equal(first.Certificate[0], second.Certificate[0]) {
+ t.Fatal("expired cached leaf was served again instead of being re-issued")
+ }
+ leaf, err := x509.ParseCertificate(second.Certificate[0])
+ if err != nil {
+ t.Fatalf("parse leaf: %v", err)
+ }
+ if !time.Now().Before(leaf.NotAfter) {
+ t.Errorf("re-issued leaf NotAfter = %v; already expired", leaf.NotAfter)
+ }
+}
+
+func TestIssueCert_ReissuesLeafInsideRenewMargin(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+ first, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert: %v", err)
+ }
+
+ ca.mu.Lock()
+ entry := ca.cache["example.com"]
+ entry.notAfter = time.Now().Add(leafRenewMargin / 2)
+ ca.cache["example.com"] = entry
+ ca.mu.Unlock()
+
+ second, err := ca.issueCert("example.com")
+ if err != nil {
+ t.Fatalf("issueCert inside margin: %v", err)
+ }
+ if bytes.Equal(first.Certificate[0], second.Certificate[0]) {
+ t.Error("leaf expiring inside the renew margin was served again")
+ }
+}
+
+func TestSave_KeyIsNotWorldReadable(t *testing.T) {
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+
+ t.Run("fresh file", func(t *testing.T) {
+ dir := t.TempDir()
+ keyPath := filepath.Join(dir, "ca.key")
+ if err := ca.Save(filepath.Join(dir, "ca.pem"), keyPath); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ assertKeyPerm(t, keyPath)
+ })
+
+ t.Run("overwrites pre-existing world readable file", func(t *testing.T) {
+ dir := t.TempDir()
+ keyPath := filepath.Join(dir, "ca.key")
+ f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE, 0o666)
+ if err != nil {
+ t.Fatalf("pre-create key: %v", err)
+ }
+ f.Close()
+ if err := os.Chmod(keyPath, 0o666); err != nil {
+ t.Fatalf("chmod: %v", err)
+ }
+ if err := ca.Save(filepath.Join(dir, "ca.pem"), keyPath); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ assertKeyPerm(t, keyPath)
+ })
+}
+
+func assertKeyPerm(t *testing.T, keyPath string) {
+ t.Helper()
+ fi, err := os.Stat(keyPath)
+ if err != nil {
+ t.Fatalf("stat key: %v", err)
+ }
+ if perm := fi.Mode().Perm(); perm&0o077 != 0 {
+ t.Errorf("CA key mode on disk = %#o; group/other must have no access", perm)
+ }
+ if _, err := loadCA(filepath.Join(filepath.Dir(keyPath), "ca.pem"), keyPath); err != nil {
+ t.Errorf("saved CA does not load back: %v", err)
+ }
+}
+
+func TestLoadOrGenerateCA_RoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ certPath := filepath.Join(dir, "ca.pem")
+ keyPath := filepath.Join(dir, "ca.key")
+
+ first, err := LoadOrGenerateCA(certPath, keyPath)
+ if err != nil {
+ t.Fatalf("LoadOrGenerateCA (generate): %v", err)
+ }
+ if _, err := os.Stat(certPath); err != nil {
+ t.Fatalf("cert was not written: %v", err)
+ }
+
+ second, err := LoadOrGenerateCA(certPath, keyPath)
+ if err != nil {
+ t.Fatalf("LoadOrGenerateCA (load): %v", err)
+ }
+ if !bytes.Equal(first.derCert, second.derCert) {
+ t.Error("second call did not load the CA written by the first")
+ }
+}
+
+func TestLoadOrGenerateCA_CorruptCert(t *testing.T) {
+ dir := t.TempDir()
+ certPath := filepath.Join(dir, "ca.pem")
+ keyPath := filepath.Join(dir, "ca.key")
+
+ ca, err := GenerateCA()
+ if err != nil {
+ t.Fatalf("GenerateCA: %v", err)
+ }
+ if err := ca.Save(certPath, keyPath); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ if err := os.WriteFile(certPath, []byte("not a certificate\n"), 0o644); err != nil {
+ t.Fatalf("corrupt cert: %v", err)
+ }
+
+ if _, err := LoadOrGenerateCA(certPath, keyPath); err == nil {
+ t.Fatal("LoadOrGenerateCA accepted a corrupt cert file")
+ }
+}
+
+func TestLoadOrGenerateCA_HalfSpecifiedPairRefused(t *testing.T) {
+ dir := t.TempDir()
+
+ if _, err := LoadOrGenerateCA(filepath.Join(dir, "ca.pem"), ""); err == nil {
+ t.Error("cert path without key path was accepted; want an error")
+ }
+ if _, err := LoadOrGenerateCA("", filepath.Join(dir, "ca.key")); err == nil {
+ t.Error("key path without cert path was accepted; want an error")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "ca.pem")); !os.IsNotExist(err) {
+ t.Error("a refused half-specified pair still wrote a cert file")
+ }
+
+ if _, err := LoadOrGenerateCA("", ""); err != nil {
+ t.Errorf("both paths empty must stay valid (ephemeral CA): %v", err)
+ }
+}
| |