edge: never serve a cached wildcard that doesn't cover its parent
2 files changed,
+75
−5
internal/edge/wildcard.go+26 −5internal/edge/wildcard_test.go+49 −0
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 5bc8d5326..0f14de237 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -25,6 +25,7 @@ package edge |
| import ( |
| "bytes" |
| "context" |
| + "crypto" |
| "crypto/ecdsa" |
| "crypto/tls" |
| "crypto/x509" |
| @@ -185,17 +186,37 @@ func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool { |
| if err == nil { |
| var cert *tls.Certificate |
| if cert, err = parseWildcardPEM(raw); err == nil { |
| - w.mu.Lock() |
| - w.certs[parent] = cert |
| - w.mu.Unlock() |
| - log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| - return true |
| + if !coversParent(cert, parent) { |
| + err = errors.New("cached cert does not cover parent or key mismatch") |
| + } else { |
| + w.mu.Lock() |
| + w.certs[parent] = cert |
| + w.mu.Unlock() |
| + log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| + return true |
| + } |
| } |
| } |
| log.Printf("edge: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", parent, err) |
| return false |
| } |
| +// coversParent reports whether cert is usable as *.<parent>: the leaf must |
| +// name a one-label host under parent and the private key must match the |
| +// leaf. A cached entry failing this is treated as unreadable — served |
| +// certs must never be worse than no cert (per-host issuance covers). |
| +func coversParent(cert *tls.Certificate, parent string) bool { |
| + if cert.Leaf == nil || cert.Leaf.VerifyHostname("acme-probe."+parent) != nil { |
| + return false |
| + } |
| + priv, ok := cert.PrivateKey.(interface{ Public() crypto.PublicKey }) |
| + if !ok { |
| + return false |
| + } |
| + pub, ok := priv.Public().(interface{ Equal(crypto.PublicKey) bool }) |
| + return ok && pub.Equal(cert.Leaf.PublicKey) |
| +} |
| + |
| // wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM — |
| // Eleven's file name, so a dir cache written by that lineage reads with no |
| // migration, and the keys sit additively beside autocert's per-host entries. |
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go| index e983e4de5..5ac7b2618 100644 |
| --- a/internal/edge/wildcard_test.go |
| +++ b/internal/edge/wildcard_test.go |
| @@ -174,3 +174,52 @@ func TestLoadCachedAndRefreshWake(t *testing.T) { |
| t.Fatal("refresh with missing cert should wake the run loop") |
| } |
| } |
| + |
| +func TestLoadCachedRejectsWrongCoverage(t *testing.T) { |
| + // (a) a cert valid for the WRONG parent, stashed under abs.oncarlos.com's |
| + // key — loadCached must refuse it and store nothing. |
| + wrongParent, wrongParentKey := selfSignedWildcard(t, "bab.oncarlos.com", time.Now().Add(60*24*time.Hour)) |
| + rawWrongParent, err := encodeWildcardPEM(wrongParent, wrongParentKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cache := autocert.DirCache(t.TempDir()) |
| + if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawWrongParent); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, nil) |
| + if w.loadCached(context.Background(), "abs.oncarlos.com") { |
| + t.Fatal("loadCached: want false for cert covering the wrong parent") |
| + } |
| + w.mu.Lock() |
| + _, stored := w.certs["abs.oncarlos.com"] |
| + w.mu.Unlock() |
| + if stored { |
| + t.Fatal("loadCached: must not store a cert that doesn't cover the parent") |
| + } |
| + |
| + // (b) a cert whose stored key does NOT match the leaf's public key. |
| + cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour)) |
| + otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rawMismatch, err := encodeWildcardPEM(cert, otherKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cache2 := autocert.DirCache(t.TempDir()) |
| + if err := cache2.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawMismatch); err != nil { |
| + t.Fatal(err) |
| + } |
| + w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache2, nil) |
| + if w2.loadCached(context.Background(), "abs.oncarlos.com") { |
| + t.Fatal("loadCached: want false for key/leaf mismatch") |
| + } |
| + w2.mu.Lock() |
| + _, stored2 := w2.certs["abs.oncarlos.com"] |
| + w2.mu.Unlock() |
| + if stored2 { |
| + t.Fatal("loadCached: must not store a cert whose key doesn't match its leaf") |
| + } |
| +} |