carlos / platform Public

edge: cert mirror review fixes — live tokens, IDN SNI, partial polls, budgets

Five review findings.

1. tls-alpn-01 token certs are keyed by HOSTNAME, not by a unique token, so
   the previous order's entry for a host is a live COLLISION rather than a
   stale copy of the same answer — serving it hands the CA the wrong key
   authorization and burns the order. Token certs are now read live, never
   from the poll's snapshot (which no longer parses them at all), and a token
   read-through is remembered for seconds rather than for a poll.

2. Unicode SNI (curl, and anything not doing IDNA itself) missed the
   punycode cache name the issuer published under, hard-failing a handshake a
   single box serves today. Converted once at the top of the chain with
   idna.Lookup.ToASCII, exactly as autocert does, and used by the mirror, the
   ALPN and the local-cache lookups alike; an unconvertible name falls
   through as the ordinary miss it is.

3. One unreadable object aborted the WHOLE poll — discarding every unrelated
   cert that landed with it, and skipping syncLocal and the wildcard wake, so
   a single permanently bad key froze the mirror forever. A per-object
   failure now carries that name's previous copy forward; only a failed List,
   or a poll where every object failed, fails the poll.

4. The :80 read-through is bounded by the host under validation (autocert's
   own HTTPHandler bound, which a real CA always satisfies), and :80 and 443
   no longer share one budget — an unauthenticated challenge flood must not
   spend the tokens the tls-alpn-01 rescue on 443 needs.

5. Comment correction, load-bearing for rollout: fleet boxes are storeless
   because they carry no bucket env, NOT because the fleet door's ACL denies
   control/certs/** — the edge never uses the door (that is the agent's
   client). A grandfathered fleet box that still carries the bucket env reads
   over its own IAM and is gated AND mirrored like any non-issuer, until the
   rollout drops that env.
Paul Campbell pushed by paul@keymail.dev eb6332a42486a0d570cd76f78d4110c62e915ba6 parent eea3f60
2 files changed, +411 −44
  • internal/edge/certsync.go +168 −44
  • internal/edge/certsync_test.go +243 −0
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go
index a12c68c58..a802607e9 100644
--- a/internal/edge/certsync.go
+++ b/internal/edge/certsync.go
@@ -35,6 +35,7 @@ import (
"errors"
"fmt"
"log"
+ "net"
"net/http"
"os"
"path"
@@ -44,6 +45,7 @@ import (
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
+ "golang.org/x/net/idna"
"github.com/carlosframework/platform/internal/release"
)
@@ -102,9 +104,15 @@ var errNotIssuer = errors.New("acme: this box is not the deployment's certificat
// serving out of its own on-disk cache, since autocert consults HostPolicy
// before its cache lookup:
//
-// - fleet boxes (CARLOS_FLEET): the housekeeper never contends there, and
-// the fleet store door's ACL denies control/certs/** by design, so no
-// future mirror can rescue them either;
+// - fleet boxes (CARLOS_FLEET) carrying no deployment bucket of their own:
+// the housekeeper never contends there, and there is nothing to mirror
+// from. The fleet store DOOR is not what decides this and never was: the
+// edge does not use the door at all — enableStaticFromEnv reads
+// CARLOS_DEPLOYMENT_BUCKET / CARLOS_DEPLOYMENT_DIR and opens the store
+// directly, and the door is the agent's client. A grandfathered fleet box
+// that still carries the bucket env therefore reads control/certs/ over
+// its own IAM, and is gated AND mirrored like any other non-issuer — a
+// transition state, resolved by the rollout dropping that env;
// - boxes with neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR;
// - boxes with a bucket but no CARLOS_RELEASE_PUBKEY, where
// enableStaticFromEnv leaves staticStore nil.
@@ -418,6 +426,9 @@ type certMirror struct {
mu sync.RWMutex
snap *certSnapshot
+ // partialPolls counts polls that could not read every object, for the
+ // first-and-every-tenth log cadence.
+ partialPolls int
// synced remembers, per name, the mirrored bytes last reconciled into the
// local cache, so a steady state costs no local I/O at all.
@@ -429,8 +440,8 @@ type certMirror struct {
rtMu sync.Mutex
rt map[string]readThroughEntry
rtInflight map[string]chan struct{}
- rtTokens float64
- rtLast time.Time
+ rtHTTP rtBudget // :80, the HTTP-01 challenge path
+ rtALPN rtBudget // 443, the tls-alpn-01 token-cert path
lastRTLog time.Time
// localCerts memoes the last-resort reads of this box's own cache;
@@ -487,7 +498,10 @@ func (m *certMirror) Refresh(ctx context.Context) error {
if err != nil {
return fmt.Errorf("list %s: %w", certPrefix, err)
}
+ prev := m.snapshot()
next := make(map[string][]byte, len(keys))
+ var tried, failed int
+ var lastErr error
for _, key := range keys {
name, ok := strings.CutPrefix(key, certPrefix)
// publishableCertName is the same filter the publish side applies:
@@ -496,18 +510,36 @@ func (m *certMirror) Refresh(ctx context.Context) error {
if !ok || !publishableCertName(name) {
continue
}
+ tried++
body, _, err := m.s.Get(ctx, key)
- if errors.Is(err, os.ErrNotExist) {
+ switch {
+ case errors.Is(err, os.ErrNotExist):
// Raced a delete between the list and the get — a spent token. Not
// a fault; the entry is simply gone.
continue
- }
- if err != nil {
- return fmt.Errorf("get %s: %w", key, err)
+ case err != nil:
+ // ONE bad object is not a bad poll. Aborting here would discard the
+ // whole rebuild — every unrelated cert that landed this tick with
+ // it — and skip syncLocal and the wildcard wake; a single
+ // permanently unreadable key would freeze the mirror forever. Carry
+ // the previous copy of this name forward and keep going.
+ failed++
+ lastErr = err
+ if body, ok := prev.entries[name]; ok {
+ next[name] = body
+ }
+ continue
}
next[name] = body
}
- prev := m.snapshot()
+ if tried > 0 && failed == tried {
+ // Nothing at all could be read: that is a bucket fault, not an object
+ // fault, and it is reported like a failed List — last-good stands.
+ return fmt.Errorf("get %s*: every one of %d object(s) failed, last: %w", certPrefix, tried, lastErr)
+ }
+ if failed > 0 {
+ m.logPartialPoll(failed, tried, lastErr)
+ }
snap := buildCertSnapshot(prev, next)
m.mu.Lock()
m.snap = snap
@@ -521,6 +553,19 @@ func (m *certMirror) Refresh(ctx context.Context) error {
return nil
}
+// logPartialPoll reports unreadable objects on the loop's own first-and-every-
+// tenth cadence: a permanently bad key would otherwise say the same thing
+// every 30 seconds forever.
+func (m *certMirror) logPartialPoll(failed, tried int, err error) {
+ m.mu.Lock()
+ m.partialPolls++
+ n := m.partialPolls
+ m.mu.Unlock()
+ if n == 1 || n%10 == 0 {
+ log.Printf("certsync: %d of %d object(s) under %s unreadable this poll (#%d, last: %v) — the previous copy of each is carried forward", failed, tried, certPrefix, n, err)
+ }
+}
+
// buildCertSnapshot parses what changed and carries the rest of the previous
// parse forward — a handshake never pays for x509 parsing, and a quiet
// deployment pays for it once.
@@ -530,6 +575,13 @@ func buildCertSnapshot(prev *certSnapshot, entries map[string][]byte) *certSnaps
if !certShapedName(name) {
continue
}
+ if strings.HasSuffix(name, alpnTokenSuffix) {
+ // tls-alpn-01 token certs are never served from a snapshot — see
+ // alpnTokenCert — so parsing them here would be work done every
+ // poll for an answer nothing may use. Leaving them unparsed also
+ // makes serving a stale one structurally impossible.
+ continue
+ }
if _, seen := prev.entries[name]; seen && bytes.Equal(prev.entries[name], body) {
// Unchanged. Carry the previous RESULT forward, failure included:
// re-parsing identical bytes cannot answer differently, and
@@ -596,7 +648,9 @@ func accountKeyName(name string) bool {
// certShapedName reports whether an entry is expected to parse as a
// certificate: everything except account keys and HTTP-01 response bodies.
// That leaves per-host certs (<host>, <host>+rsa), wildcard PEMs
-// (wildcard-<parent>.pem) and tls-alpn-01 token certs (<host>+token).
+// (wildcard-<parent>.pem) and tls-alpn-01 token certs (<host>+token) — the
+// last of which only the live read-through parses (buildCertSnapshot skips
+// them).
func certShapedName(name string) bool {
return !accountKeyName(name) && !strings.HasSuffix(name, httpTokenSuffix)
}
@@ -818,7 +872,10 @@ func wildcardMaterialChanged(prev, next *certSnapshot) bool {
// behind it as a fallback for anything autocert cannot serve.
func (m *certMirror) wrapGetCertificate(inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
- name := strings.ToLower(strings.TrimSuffix(hello.ServerName, "."))
+ // Converted ONCE, here, and used by every layer below: the mirror
+ // lookup, the tls-alpn-01 lookup and the local cache read all have to
+ // agree with the name autocert published under.
+ name := serverName(hello.ServerName)
if len(hello.SupportedProtos) == 1 && hello.SupportedProtos[0] == acme.ALPNProto {
// A tls-alpn-01 validation. autocert answers its OWN order from
// memory; anything else is another edge's order, and the CA reached
@@ -855,6 +912,24 @@ func (m *certMirror) wrapGetCertificate(inner func(*tls.ClientHelloInfo) (*tls.C
}
}
+// serverName normalizes a client-supplied name to the cache name autocert
+// would have used: lowercased, no trailing dot, and punycode via
+// idna.Lookup.ToASCII — the same conversion autocert applies before its own
+// cache lookup (autocert.go:307), because some clients (curl among them) send
+// unicode SNI verbatim and a cert is always published under the A-label.
+//
+// A name IDNA refuses is kept in its lowercased form rather than dropped:
+// every layer below treats it as an ordinary miss, which is what it is, and
+// the refusal belongs to autocert (which rejects it outright) rather than to
+// the mirror.
+func serverName(sni string) string {
+ name := strings.ToLower(strings.TrimSuffix(sni, "."))
+ if ascii, err := idna.Lookup.ToASCII(name); err == nil {
+ return ascii
+ }
+ return name
+}
+
// isGated reads the issuance gate, defaulting to "gated" (see the field).
func (m *certMirror) isGated() bool {
return m.gated == nil || m.gated()
@@ -1034,7 +1109,7 @@ func (m *certMirror) challengeHandler(next http.Handler) http.Handler {
}
// path.Base is the token autocert itself would derive
// (httpTokenCacheKey), so both handlers agree on the name.
- if body, ok := m.challengeResponse(r.Context(), path.Base(r.URL.Path)); ok {
+ if body, ok := m.challengeResponse(r.Context(), requestHost(r.Host), path.Base(r.URL.Path)); ok {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write(body)
return
@@ -1048,17 +1123,39 @@ func (m *certMirror) challengeHandler(next http.Handler) http.Handler {
// life is a few seconds: the issuer writes the token, sends Accept, and the
// CA arrives at whichever edge DNS handed it. A poll that runs every 30s
// misses that window every time.
-func (m *certMirror) challengeResponse(ctx context.Context, token string) ([]byte, bool) {
+func (m *certMirror) challengeResponse(ctx context.Context, host, token string) ([]byte, bool) {
if body, ok := m.Challenge(token); ok {
+ // Already polled: a map hit costs nothing and the token is the CA's own
+ // 128+ bits of entropy, so it needs no host bound of its own.
return body, true
}
if !validChallengeToken(token) {
return nil, false
}
- body, _, ok := m.readThrough(ctx, token+httpTokenSuffix)
+ // The LIVE lookup is bounded by the host under validation, exactly as
+ // autocert bounds its own token lookup (HTTPHandler runs hostPolicy on
+ // r.Host first, autocert.go:405). A real CA always sends the domain it is
+ // validating, so the bound costs a genuine validation nothing — and
+ // without it an unauthenticated :80 flood sustains a bucket GET per
+ // distinct path, forever, from anywhere.
+ if m.allowed != nil && !m.allowed(host) {
+ return nil, false
+ }
+ body, _, ok := m.readThrough(ctx, token+httpTokenSuffix, &m.rtHTTP)
return body, ok
}
+// requestHost is the Host header without its port, normalized the same way
+// SNI is (serverName) so it meets Edge.Allowed's exact registry map in the
+// shape routes are stored in.
+func requestHost(hostport string) string {
+ host := hostport
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ return serverName(host)
+}
+
// validChallengeToken bounds what an unauthenticated URL path may turn into a
// bucket read: ACME tokens are base64url of at least 128 bits of entropy, so
// nothing shorter or otherwise shaped can be one. It also keeps the key
@@ -1091,14 +1188,17 @@ func (m *certMirror) alpnTokenCert(ctx context.Context, host string) *tls.Certif
if host == "" {
return nil
}
- key := host + alpnTokenSuffix
- if c := m.snapshot().certs[key]; usableFor(c, host) {
- return c
- }
if m.allowed != nil && !m.allowed(host) {
return nil
}
- _, cert, _ := m.readThrough(ctx, key)
+ // Deliberately NOT from the poll's snapshot. Unlike an HTTP-01 entry —
+ // whose name carries the order's unique token — a token CERT is keyed by
+ // hostname alone, so the previous order's entry for the same host is a
+ // live collision, not a stale copy of the same answer. Serving it hands
+ // the CA the wrong key authorization and burns the order. The snapshot is
+ // a poll behind at best, and far worse if a gated Delete left litter
+ // behind, so this material is only ever read live.
+ _, cert, _ := m.readThrough(ctx, host+alpnTokenSuffix, &m.rtALPN)
if usableFor(cert, host) {
return cert
}
@@ -1112,21 +1212,48 @@ const (
// handshake or a :80 request, so it is short by construction.
readThroughTimeout = 2 * time.Second
// readThroughHitTTL is about one poll: after that the poll itself holds
- // the entry (or it has been withdrawn, which is also the answer).
+ // the entry (or it has been withdrawn, which is also the answer). It
+ // applies to names that are unique per order — HTTP-01 tokens.
readThroughHitTTL = 30 * time.Second
// readThroughMissTTL keeps a scanner walking the challenge path from
// turning into a bucket GET per request.
readThroughMissTTL = 5 * time.Second
// readThroughCacheMax bounds the memo; over it, the map is dropped whole.
readThroughCacheMax = 512
- // readThroughBurst / readThroughRefill are the global budget: distinct
+ // readThroughBurst / readThroughRefill are one budget's rate: distinct
// names defeat the negative cache, so the last bound is a rate. Under a
// flood the read-through degrades to "off" — the 30s poll still covers
// everything that is not a live validation.
+ //
+ // There is one budget PER LISTENER (rtHTTP, rtALPN), never one shared:
+ // :80's challenge path is reachable by anyone, and a flood there must not
+ // spend the budget 443's tls-alpn-01 rescue needs. They fail
+ // independently or not at all.
readThroughBurst = 20
readThroughRefill = 5 // per second
)
+// rtBudget is one token bucket. Guarded by certMirror.rtMu.
+type rtBudget struct {
+ tokens float64
+ last time.Time
+}
+
+// spend takes one token, reporting whether there was one. Caller holds rtMu.
+func (b *rtBudget) spend(now time.Time) bool {
+ if b.last.IsZero() {
+ b.tokens = readThroughBurst
+ } else {
+ b.tokens = min(readThroughBurst, b.tokens+now.Sub(b.last).Seconds()*readThroughRefill)
+ }
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
type readThroughEntry struct {
body []byte
cert *tls.Certificate
@@ -1134,10 +1261,24 @@ type readThroughEntry struct {
at time.Time
}
-func (e readThroughEntry) fresh() bool {
+// readThroughTokenTTL is how long a tls-alpn-01 token CERT may be remembered.
+// Seconds, not a poll: the name is the bare hostname, so the next order for
+// that host reuses it, and two orders can be seconds apart. A var so tests
+// can zero it.
+var readThroughTokenTTL = 3 * time.Second
+
+// readThroughTTL is how long a HIT for name may be reused.
+func readThroughTTL(name string) time.Duration {
+ if strings.HasSuffix(name, alpnTokenSuffix) {
+ return readThroughTokenTTL
+ }
+ return readThroughHitTTL
+}
+
+func (e readThroughEntry) fresh(name string) bool {
ttl := readThroughMissTTL
if e.ok {
- ttl = readThroughHitTTL
+ ttl = readThroughTTL(name)
}
return time.Since(e.at) < ttl
}
@@ -1147,9 +1288,9 @@ func (e readThroughEntry) fresh() bool {
// budgeted per call. Errors are answers here, not faults — a bucket that is
// down simply means this box cannot help with someone else's live
// validation, and the CA retries.
-func (m *certMirror) readThrough(ctx context.Context, name string) ([]byte, *tls.Certificate, bool) {
+func (m *certMirror) readThrough(ctx context.Context, name string, budget *rtBudget) ([]byte, *tls.Certificate, bool) {
m.rtMu.Lock()
- if e, ok := m.rt[name]; ok && e.fresh() {
+ if e, ok := m.rt[name]; ok && e.fresh(name) {
m.rtMu.Unlock()
return e.body, e.cert, e.ok
}
@@ -1167,7 +1308,7 @@ func (m *certMirror) readThrough(ctx context.Context, name string) ([]byte, *tls
m.rtMu.Unlock()
return e.body, e.cert, e.ok
}
- if !m.allowFetchLocked() {
+ if !budget.spend(time.Now()) {
m.rtMu.Unlock()
return nil, nil, false
}
@@ -1221,20 +1362,3 @@ func (m *certMirror) logReadThroughError(name string, err error) {
log.Printf("certsync: live read of %q failed: %v — the %s poll still covers everything but live validations", name, err, certMirrorInterval)
}
}
-
-// allowFetchLocked spends one token from the read-through budget. Caller
-// holds rtMu.
-func (m *certMirror) allowFetchLocked() bool {
- now := time.Now()
- if m.rtLast.IsZero() {
- m.rtTokens = readThroughBurst
- } else {
- m.rtTokens = min(readThroughBurst, m.rtTokens+now.Sub(m.rtLast).Seconds()*readThroughRefill)
- }
- m.rtLast = now
- if m.rtTokens < 1 {
- return false
- }
- m.rtTokens--
- return true
-}
diff --git a/internal/edge/certsync_test.go b/internal/edge/certsync_test.go
index 34f0d8946..5934656b5 100644
--- a/internal/edge/certsync_test.go
+++ b/internal/edge/certsync_test.go
@@ -1426,3 +1426,246 @@ func TestMirrorWithNoGateDefaultsToServingFirst(t *testing.T) {
t.Fatalf("autocert was consulted %d time(s) — with no gate the mirror answers first", n)
}
}
+
+// ---- review fixes -----------------------------------------------------------
+
+// keyFailingStore fails Get for exactly the keys named (all of them when
+// `all` is set), and counts every Get.
+type keyFailingStore struct {
+ release.Store
+ mu sync.Mutex
+ fail map[string]bool
+ all bool
+ n int
+}
+
+func (k *keyFailingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ k.mu.Lock()
+ k.n++
+ bad := k.all || k.fail[key]
+ k.mu.Unlock()
+ if bad {
+ return nil, "", errors.New("object unreadable")
+ }
+ return k.Store.Get(ctx, key)
+}
+
+func (k *keyFailingStore) setFail(keys ...string) {
+ k.mu.Lock()
+ k.fail = map[string]bool{}
+ for _, key := range keys {
+ k.fail[key] = true
+ }
+ k.mu.Unlock()
+}
+
+func (k *keyFailingStore) setAll(v bool) {
+ k.mu.Lock()
+ k.all = v
+ k.mu.Unlock()
+}
+
+func (k *keyFailingStore) gets() int {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ return k.n
+}
+
+// FINDING 3: one unreadable object used to discard the WHOLE rebuild — every
+// unrelated new cert with it, plus syncLocal and the wildcard wake. A poll
+// fails only when the listing fails, or when nothing at all could be read.
+func TestCertMirrorPollCarriesUnreadableObjectsForward(t *testing.T) {
+ inner := release.NewDirStore(t.TempDir())
+ s := &keyFailingStore{Store: inner}
+ putBucket(t, inner, "a.example.com", []byte("A"))
+ putBucket(t, inner, "b.example.com", []byte("B"))
+ local := autocert.DirCache(t.TempDir())
+ m := newCertMirror(s, local)
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+
+ // a.example.com goes unreadable; a brand-new cert lands at the same time.
+ s.setFail(certKey("a.example.com"))
+ putBucket(t, inner, "c.example.com", hostCertPEM(t, "c.example.com", time.Now().Add(80*24*time.Hour)))
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatalf("one bad object must not fail the whole poll: %v", err)
+ }
+ if b, ok := m.Cert("a.example.com"); !ok || string(b) != "A" {
+ t.Fatalf("the unreadable object's previous copy = %q, %v — want it carried forward", b, ok)
+ }
+ if _, ok := m.Cert("c.example.com"); !ok {
+ t.Fatal("the new cert must land even though a sibling object was unreadable")
+ }
+ if _, err := local.Get(context.Background(), "c.example.com"); err != nil {
+ t.Fatalf("syncLocal must still run on a partially failed poll: %v", err)
+ }
+
+ // Everything unreadable IS a failed poll — and the last-good map stands.
+ s.setAll(true)
+ if err := m.Refresh(context.Background()); err == nil {
+ t.Fatal("a poll where every object failed must be reported")
+ }
+ if _, ok := m.Cert("b.example.com"); !ok {
+ t.Fatal("a failed poll must keep the last-good map")
+ }
+}
+
+// FINDING 1: tls-alpn-01 token certs are keyed by HOSTNAME, not by a unique
+// token, so a previous order's entry for the same host is a live collision.
+// The snapshot is up to a poll stale (and, if a gated Delete leaked, far
+// staler than that) — token certs are read live, never served from the poll.
+func TestALPNTokenCertIsReadLiveNotFromTheSnapshot(t *testing.T) {
+ inner := release.NewDirStore(t.TempDir())
+ s := &bucketGetCounter{Store: inner}
+ stale := hostCertPEM(t, mirrorHost, time.Now().Add(24*time.Hour))
+ putBucket(t, inner, mirrorHost+"+token", stale)
+
+ m := newCertMirror(s, nil)
+ m.allowed = func(h string) bool { return h == mirrorHost }
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ // A new order for the same host replaces the token cert in the bucket.
+ fresh := hostCertPEM(t, mirrorHost, time.Now().Add(23*time.Hour))
+ putBucket(t, inner, mirrorHost+"+token", fresh)
+
+ get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost)
+ got, err := get(alpnHello(mirrorHost))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want, err := parseCertEntry(fresh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got.Certificate[0], want.Certificate[0]) {
+ t.Fatal("the CA was handed the PREVIOUS order's token cert — the validation fails and the order burns")
+ }
+ if s.gets() == 0 {
+ t.Fatal("a token cert must be read live, not served out of the poll's snapshot")
+ }
+}
+
+// ...and a token read-through is remembered for seconds, not for a poll: two
+// orders for one host can be minutes apart, but they can also be seconds.
+func TestALPNTokenReadThroughTTLIsShort(t *testing.T) {
+ if readThroughTTL(mirrorHost+alpnTokenSuffix) > 5*time.Second {
+ t.Fatalf("token TTL = %s, want a few seconds at most", readThroughTTL(mirrorHost+alpnTokenSuffix))
+ }
+ if got := readThroughTTL("tok" + httpTokenSuffix); got != readThroughHitTTL {
+ t.Fatalf("HTTP-01 token TTL = %s, want %s — those names are unique per order", got, readThroughHitTTL)
+ }
+
+ prev := readThroughTokenTTL
+ readThroughTokenTTL = 0
+ t.Cleanup(func() { readThroughTokenTTL = prev })
+ inner := release.NewDirStore(t.TempDir())
+ s := &bucketGetCounter{Store: inner}
+ putBucket(t, inner, mirrorHost+"+token", hostCertPEM(t, mirrorHost, time.Now().Add(24*time.Hour)))
+ m := newCertMirror(s, nil)
+ m.allowed = func(string) bool { return true }
+ get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost)
+ for range 2 {
+ if _, err := get(alpnHello(mirrorHost)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if n := s.gets(); n != 2 {
+ t.Fatalf("bucket gets = %d, want 2 — an expired token memo must be re-read", n)
+ }
+}
+
+// FINDING 2: some clients (curl, and anything not doing IDNA itself) send
+// unicode SNI. autocert converts with idna.Lookup.ToASCII before it looks in
+// its cache (autocert.go:307); a mirror that did not would hard-fail a
+// handshake the same box serves today.
+func TestMirrorConvertsUnicodeSNIToPunycode(t *testing.T) {
+ const unicodeHost, asciiHost = "bücher.example", "xn--bcher-kva.example"
+ s := release.NewDirStore(t.TempDir())
+ putBucket(t, s, asciiHost, hostCertPEM(t, asciiHost, time.Now().Add(80*24*time.Hour)))
+ m := newCertMirror(s, nil)
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), asciiHost)
+ got, err := get(ecdsaHello(unicodeHost))
+ if err != nil {
+ t.Fatalf("unicode SNI must find the punycode cache name: %v", err)
+ }
+ if got.Leaf.VerifyHostname(asciiHost) != nil {
+ t.Fatal("served cert does not cover the punycode host")
+ }
+ // The last-resort local read looks under the same converted name.
+ local := autocert.DirCache(t.TempDir())
+ if err := local.Put(context.Background(), asciiHost, hostCertPEM(t, asciiHost, time.Now().Add(80*24*time.Hour))); err != nil {
+ t.Fatal(err)
+ }
+ m2 := newCertMirror(release.NewDirStore(t.TempDir()), local)
+ get2, _ := gatedChain(t, m2, local, asciiHost)
+ if _, err := get2(ecdsaHello(unicodeHost)); err != nil {
+ t.Fatalf("the local last resort must convert too: %v", err)
+ }
+}
+
+// A name IDNA cannot convert (and an empty one) must not panic or fetch: it
+// falls through as an ordinary miss.
+func TestMirrorSurvivesUnconvertibleSNI(t *testing.T) {
+ m := newCertMirror(release.NewDirStore(t.TempDir()), nil)
+ get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost)
+ for _, sni := range []string{"", "-.-", strings.Repeat("x", 300) + ".example"} {
+ if _, err := get(ecdsaHello(sni)); err == nil {
+ t.Fatalf("%q: want a miss, got a certificate", sni)
+ }
+ }
+}
+
+// challengeRequestHost is challengeRequest with an explicit Host header.
+func challengeRequestHost(t *testing.T, h http.Handler, host, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ r := httptest.NewRequest("GET", "http://"+host+"/.well-known/acme-challenge/"+token, nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+// FINDING 4a: the live lookup on :80 is bounded by the host under
+// validation, exactly as autocert's own handler bounds its token lookup. A
+// real CA always sends it; a flood against hosts this box does not serve
+// never reaches the bucket.
+func TestChallengeReadThroughIsBoundedByHost(t *testing.T) {
+ inner := release.NewDirStore(t.TempDir())
+ putBucket(t, inner, mirrorTestToken+"+http-01", []byte("keyauth"))
+ s := &bucketGetCounter{Store: inner}
+ m := newCertMirror(s, nil)
+ m.allowed = func(h string) bool { return h == mirrorHost }
+ h := gatedHTTPHandler(t, m)
+
+ if got := challengeRequestHost(t, h, "stranger.example", mirrorTestToken); got.Code == 200 {
+ t.Fatal("a host this box does not serve must not drive a bucket read")
+ }
+ if n := s.gets(); n != 0 {
+ t.Fatalf("bucket gets for an unserved host = %d, want 0", n)
+ }
+ if got := challengeRequestHost(t, h, mirrorHost+":80", mirrorTestToken); got.Code != 200 {
+ t.Fatalf("the host under validation must be answered: %d", got.Code)
+ }
+}
+
+// FINDING 4b: :80 and 443 do not share one budget. An unauthenticated flood
+// of challenge requests must not starve a tls-alpn-01 rescue on 443.
+func TestChallengeFloodDoesNotStarveALPNRescue(t *testing.T) {
+ inner := release.NewDirStore(t.TempDir())
+ putBucket(t, inner, mirrorHost+"+token", hostCertPEM(t, mirrorHost, time.Now().Add(24*time.Hour)))
+ s := &bucketGetCounter{Store: inner}
+ m := newCertMirror(s, nil)
+ m.allowed = func(string) bool { return true }
+ h := gatedHTTPHandler(t, m)
+ for i := range 200 {
+ challengeRequest(t, h, fmt.Sprintf("%s%03d", strings.Repeat("z", 39), i))
+ }
+ get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost)
+ if _, err := get(alpnHello(mirrorHost)); err != nil {
+ t.Fatalf("a :80 flood starved the 443 validation path: %v", err)
+ }
+}