carlos / platform Public

edge: the three cert gates admit the deployment's exact names (#277)

DNS for a home-boxed route can point at any edge. That edge backhauls to
the home box with the real SNI, the home box (as issuer) orders — and the
CA then validates against the box DNS names, whose registry does not carry
the host. Both read-throughs refused it, every authorization failed, and
five failures burned the name's Let's Encrypt per-hostname budget in
minutes while the route was down.

So issuerHostPolicy's order gate and the mirror's two read-throughs
(m.allowed, one field, both paths) now bound on deploymentHost: this box's
routes, OR an EXACT key of the mirrored home map. Exact-only is the whole
discipline — a label under a mirrored catch-all still drives no order and
spends no read-through, which is PR #180's rule unchanged and merely
widened from "this box's routes" to "this deployment's exactly-named
routes". Both halves stay registry-backed: edges files are written by each
box's own loop from its own registry.

Untouched: Allowed itself, lookupUpstream, the response cache,
unknownHost, the wildcard parent set, the backhaul path, and the
errNotIssuer gate — non-issuers still order nothing, for any name.

Read-side only, no wire or storage shape, no knob. Dormant on a single-box
deployment, where the fold is empty and every gate reduces to Allowed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0d0d02f1e129c47eb6c1353d894ffe5429d4f25e parent b069537
3 files changed, +256 −23
  • internal/edge/certsync.go +33 −13
  • internal/edge/certsync_test.go +192 −10
  • internal/edge/edge.go +31 −0
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go
index a1c0c23d7..06eaeabc3 100644
--- a/internal/edge/certsync.go
+++ b/internal/edge/certsync.go
@@ -141,7 +141,15 @@ func (e *Edge) armCertIssuance(inner autocert.Cache) autocert.Cache {
}
// issuerHostPolicy is the autocert HostPolicy: the registry check this edge
-// has always applied (Allowed), plus the issuance gate.
+// has always applied, plus the issuance gate.
+//
+// The registry check is deploymentHost (edge.go) rather than Allowed: a
+// deployment's issuer must be able to mint for any EXACTLY-named route in the
+// deployment, not only the ones this box happens to home, or a home-boxed
+// route whose hellos reach the issuer through the backhaul can never get a
+// certificate at all (#277). Exact-only, so PR #180's rule is untouched: a
+// label under a catch-all still drives no order. The errNotIssuer gate below
+// is unaffected — non-issuers still order nothing, for any name.
//
// Two autocert call sites reach this, and both are deliberate:
// GetCertificate consults the policy BEFORE its cache lookup
@@ -176,7 +184,7 @@ func (e *Edge) armCertIssuance(inner autocert.Cache) autocert.Cache {
// order for a deployment that can stop issuing altogether.
func (e *Edge) issuerHostPolicy() autocert.HostPolicy {
return func(_ context.Context, host string) error {
- if !e.Allowed(host) {
+ if !e.deploymentHost(host) {
return fmt.Errorf("acme: host %q not in registry", host)
}
if _, ok := e.issuerGate(); !ok {
@@ -420,9 +428,12 @@ type certMirror struct {
// instead of on the manager's own 12h tick. nil when wildcards are off.
wake func()
- // allowed reports whether a hostname is one this box serves (Edge.Allowed)
- // — the bound on the tls-alpn-01 read-through, whose key is
- // attacker-chosen SNI. nil = unbounded, which is tests only.
+ // allowed reports whether a hostname is one this box serves, or one
+ // another box in this deployment exactly homes (Edge.deploymentHost) —
+ // the bound on BOTH read-throughs, whose keys are attacker-chosen SNI and
+ // an attacker-chosen Host header. Exact names only, either side: a label
+ // under a catch-all spends neither budget (#180, and KnownHost's doc).
+ // nil = unbounded, which is tests only.
allowed func(string) bool
// gated reports whether issuance is currently BARRED on this box, which
@@ -485,11 +496,15 @@ func (e *Edge) newCertMirror(local autocert.Cache) *certMirror {
_, held := e.issuerGate()
return !held
}
- // The tls-alpn-01 read-through is keyed by attacker-chosen SNI, so it is
- // bounded to the hosts this box actually serves. Allowed is the same
- // exact-match registry check autocert's HostPolicy uses, which is the
- // right bound: a per-host order only ever exists for a name that passed it.
- m.allowed = e.Allowed
+ // Both read-throughs are keyed by remote input (SNI on 443, the Host
+ // header on :80), so they are bounded to the names this DEPLOYMENT serves:
+ // this box's own routes, plus the exact names another box's edges file
+ // homes. deploymentHost is the same exact-match, registry-backed check
+ // autocert's HostPolicy uses (issuerHostPolicy, above), which is the right
+ // bound for the same reason it always was — a per-host order only ever
+ // exists for a name that passed it — now that the policy admits the
+ // deployment's remote-homed routes too (#277).
+ m.allowed = e.deploymentHost
return m
}
@@ -1160,7 +1175,10 @@ func (m *certMirror) challengeResponse(ctx context.Context, host, token string)
// 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.
+ // distinct path, forever, from anywhere. The bound is the DEPLOYMENT's
+ // exact names, not this box's (m.allowed, Edge.deploymentHost): DNS for a
+ // home-boxed route can point at any edge, and the one it points at is the
+ // one the CA arrives at (#277).
if m.allowed != nil && !m.allowed(host) {
return nil, false
}
@@ -1205,8 +1223,10 @@ func validChallengeToken(token string) bool {
// validation with probability (N-1)/N before it ever tries the HTTP path.
//
// Bounded harder than the HTTP-01 path, because the name here is
-// attacker-chosen SNI rather than a high-entropy token: only hosts this box
-// actually serves are ever looked up.
+// attacker-chosen SNI rather than a high-entropy token: only names this
+// deployment serves — this box's own routes, or another box's exactly-named
+// ones (m.allowed, Edge.deploymentHost) — are ever looked up. A stranger's
+// invented label under a mirrored catch-all is not one of them.
func (m *certMirror) alpnTokenCert(ctx context.Context, host string) *tls.Certificate {
if host == "" {
return nil
diff --git a/internal/edge/certsync_test.go b/internal/edge/certsync_test.go
index 5934656b5..7e830e3c1 100644
--- a/internal/edge/certsync_test.go
+++ b/internal/edge/certsync_test.go
@@ -890,7 +890,14 @@ const mirrorHost = "app.bab.oncarlos.com"
// pass on the other.
func gatedChain(t *testing.T, m *certMirror, local autocert.Cache, hosts ...string) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), *countingCache) {
t.Helper()
- e := testIssuerEdge(hosts...)
+ return gatedChainFor(t, testIssuerEdge(hosts...), m, local)
+}
+
+// gatedChainFor is gatedChain over an edge the caller built — the form a test
+// needs when the edge carries something beyond its route table, such as the
+// mirrored home map the cert gates now consult (deploymentHost, #277).
+func gatedChainFor(t *testing.T, e *Edge, m *certMirror, local autocert.Cache) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), *countingCache) {
+ t.Helper()
e.issuer = func() (context.Context, bool) { return nil, false } // never the issuer
counting := &countingCache{Cache: local, gets: map[string]int{}}
mgr := offlineManager(t, counting, e.issuerHostPolicy())
@@ -1059,15 +1066,17 @@ func TestMirrorAnswersAnotherEdgesALPNValidation(t *testing.T) {
}
}
-// The SNI here is attacker-chosen, so the live lookup is bounded to hosts
-// this box actually serves.
-func TestMirrorALPNReadThroughOnlyForServedHosts(t *testing.T) {
+// The SNI here is attacker-chosen, so the live lookup is bounded to the names
+// this DEPLOYMENT serves — this box's own routes, or another box's
+// exactly-named ones (#277). A stranger is refused before any bucket read, as
+// it always was.
+func TestMirrorALPNReadThroughOnlyForServedOrRemoteHomedHosts(t *testing.T) {
s := &bucketGetCounter{Store: release.NewDirStore(t.TempDir())}
m := newCertMirror(s, nil)
get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost)
m.allowed = func(string) bool { return false }
if _, err := get(alpnHello("stranger.example")); err == nil {
- t.Fatal("there is no token cert for a host this box does not serve")
+ t.Fatal("there is no token cert for a host this deployment does not serve")
}
if n := s.gets(); n != 0 {
t.Fatalf("bucket gets = %d, want 0 for an unserved host", n)
@@ -1124,17 +1133,20 @@ const mirrorTestToken = "0123456789abcdefghij0123456789abcdefghijkl"
func challengeRequest(t *testing.T, h http.Handler, token string) *httptest.ResponseRecorder {
t.Helper()
- r := httptest.NewRequest("GET", "http://"+mirrorHost+"/.well-known/acme-challenge/"+token, nil)
- w := httptest.NewRecorder()
- h.ServeHTTP(w, r)
- return w
+ return challengeRequestHost(t, h, mirrorHost, token)
}
// gatedHTTPHandler is the :80 chain of a non-issuer: the mirror's handler in
// front of autocert's, which 403s every challenge on this box.
func gatedHTTPHandler(t *testing.T, m *certMirror) http.Handler {
t.Helper()
- e := testIssuerEdge(mirrorHost)
+ return gatedHTTPHandlerFor(t, testIssuerEdge(mirrorHost), m)
+}
+
+// gatedHTTPHandlerFor is gatedHTTPHandler over an edge the caller built, for
+// the same reason gatedChainFor exists.
+func gatedHTTPHandlerFor(t *testing.T, e *Edge, m *certMirror) http.Handler {
+ t.Helper()
e.issuer = func() (context.Context, bool) { return nil, false }
mgr := offlineManager(t, autocert.DirCache(t.TempDir()), e.issuerHostPolicy())
fallback := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -1669,3 +1681,173 @@ func TestChallengeFloodDoesNotStarveALPNRescue(t *testing.T) {
t.Fatalf("a :80 flood starved the 443 validation path: %v", err)
}
}
+
+// ---- #277: the three gates admit the deployment's exactly-named hosts -------
+//
+// DNS for a home-boxed route can point at ANY edge. The edge it points at
+// backhauls to the home box with the real SNI, the home box (as issuer)
+// orders — and the CA then validates against the box DNS names, which is the
+// one whose registry does not carry the host. Bounding the gates by Allowed
+// meant every such validation failed, five failures burned the name's Let's
+// Encrypt per-hostname budget in minutes, and the route was down throughout.
+//
+// The gates now ask deploymentHost: this box's routes, OR an exact key of the
+// mirrored home map. Exact is the whole discipline — every test below that
+// says "wildcard label" is PR #180's rule standing on the new surface.
+
+// remoteHomedEdge is one box of a two-box deployment: it serves served from
+// its own registry, and mirrors an edges file in which box "betty" homes
+// homed (pass "*.<parent>" to mirror a catch-all row, which Task 1 publishes
+// verbatim). The handle it issues and mirrors through COUNTS bucket Gets, so
+// "did this gate spend a read-through?" is observable; the home map polls the
+// same directory through its own handle, so the map's own reads never show up
+// in that count.
+func remoteHomedEdge(t *testing.T, served []string, homed ...string) (*Edge, *bucketGetCounter, release.Store) {
+ t.Helper()
+ base := release.NewDirStore(t.TempDir())
+ counting := &bucketGetCounter{Store: base}
+ e := testIssuerEdge(served...)
+ e.staticStore = counting
+ m := newHomeMap(base, "annie")
+ homedOn(t, m, "betty", "2.2.2.2", homed...)
+ e.homes.Store(m)
+ return e, counting, base
+}
+
+const (
+ // remoteHomedHost is homed on the OTHER box: absent from this box's
+ // registry, exactly named in its home map.
+ remoteHomedHost = "app.remote.test"
+ // remoteCatchAll is the catch-all row that box publishes, and
+ // remoteWildcardLabel is a label under it — dialable through Home()'s
+ // one-label rule, and admissible by none of the three cert gates.
+ remoteCatchAll = "*.remote.test"
+ remoteWildcardLabel = "invented.remote.test"
+)
+
+// HTTP-01, the failing half of #277: the CA arrives at a box that does not
+// serve the name, and the token is in the bucket. It is answered.
+func TestChallengeReadThroughAdmitsRemoteHomedHost(t *testing.T) {
+ e, counting, base := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost)
+ putBucket(t, base, mirrorTestToken+"+http-01", []byte("keyauth"))
+ m := e.newCertMirror(nil) // production wiring: m.allowed = e.deploymentHost
+ if m == nil {
+ t.Fatal("a store-backed edge must have a mirror")
+ }
+ if e.Allowed(remoteHomedHost) {
+ t.Fatal("test bug: the host is supposed to be absent from THIS box's registry")
+ }
+ got := challengeRequestHost(t, gatedHTTPHandlerFor(t, e, m), remoteHomedHost, mirrorTestToken)
+ if got.Code != 200 || got.Body.String() != "keyauth" {
+ t.Fatalf("challenge for a remote-homed host = %d %q, want 200 \"keyauth\"", got.Code, got.Body.String())
+ }
+ if n := counting.gets(); n != 1 {
+ t.Fatalf("bucket gets = %d, want exactly 1 — the live read-through", n)
+ }
+}
+
+// ...and the anti-stranger bound the widening had to survive: a name in
+// neither the registry nor the map spends nothing at all, and neither does an
+// invented label under a MIRRORED catch-all (#180).
+func TestChallengeReadThroughStillRefusesStrangers(t *testing.T) {
+ for _, host := range []string{"stranger.example", remoteWildcardLabel, remoteCatchAll} {
+ t.Run(host, func(t *testing.T) {
+ e, counting, base := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost, remoteCatchAll)
+ putBucket(t, base, mirrorTestToken+"+http-01", []byte("keyauth"))
+ m := e.newCertMirror(nil)
+ if got := challengeRequestHost(t, gatedHTTPHandlerFor(t, e, m), host, mirrorTestToken); got.Code == 200 {
+ t.Fatalf("%s was answered a live token body", host)
+ }
+ if n := counting.gets(); n != 0 {
+ t.Fatalf("bucket gets = %d, want 0 — %s must never drive a bucket read", n, host)
+ }
+ })
+ }
+}
+
+// tls-alpn-01, the half autocert tries FIRST: the validation cert for another
+// edge's order is served for a remote-homed name.
+func TestMirrorALPNReadThroughAdmitsRemoteHomedHost(t *testing.T) {
+ e, _, base := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost)
+ putBucket(t, base, remoteHomedHost+"+token", hostCertPEM(t, remoteHomedHost, time.Now().Add(24*time.Hour)))
+ m := e.newCertMirror(nil)
+ get, _ := gatedChainFor(t, e, m, autocert.DirCache(t.TempDir()))
+ // Deliberately NOT refreshed: a validation is live, and the poll is 30s.
+ if _, err := get(alpnHello(remoteHomedHost)); err != nil {
+ t.Fatalf("an edge DNS points at must answer the home box's tls-alpn-01 validation: %v", err)
+ }
+}
+
+// The #180 pin on the 443 surface: SNI is attacker-chosen, and a mirrored
+// catch-all must not turn an invented label into a bucket read.
+func TestALPNReadThroughRefusesWildcardLabelUnderRemoteCatchAll(t *testing.T) {
+ e, counting, base := remoteHomedEdge(t, []string{mirrorHost}, remoteCatchAll)
+ putBucket(t, base, remoteWildcardLabel+"+token", hostCertPEM(t, remoteWildcardLabel, time.Now().Add(24*time.Hour)))
+ if _, _, ok := e.homes.Load().Home(remoteWildcardLabel); !ok {
+ t.Fatal("test bug: the label is supposed to be DIALABLE through the one-label rule")
+ }
+ m := e.newCertMirror(nil)
+ get, _ := gatedChainFor(t, e, m, autocert.DirCache(t.TempDir()))
+ if _, err := get(alpnHello(remoteWildcardLabel)); err == nil {
+ t.Fatal("a label under a mirrored catch-all must not be served a token cert")
+ }
+ if n := counting.gets(); n != 0 {
+ t.Fatalf("bucket gets = %d, want 0 — the label must not spend the ALPN budget", n)
+ }
+}
+
+// The order gate: the issuer mints for any exactly-named route in the
+// deployment, whichever box homes it. Without this the home-boxed route can
+// only ever mint while the housekeeper lease sits on its own box.
+func TestIssuerOrdersForRemoteHomedHost(t *testing.T) {
+ e, _, _ := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost)
+ e.issuer = func() (context.Context, bool) { return context.Background(), true }
+ if err := e.issuerHostPolicy()(context.Background(), remoteHomedHost); err != nil {
+ t.Fatalf("HostPolicy for a remote-homed host = %v, want nil", err)
+ }
+ if err := e.issuerHostPolicy()(context.Background(), mirrorHost); err != nil {
+ t.Fatalf("HostPolicy for this box's own route = %v, want nil", err)
+ }
+}
+
+// The #180 pin on the order gate, and the one that matters most: an
+// attacker-chosen label must never drive a fresh per-host ACME order against
+// the deployment's single shared account and rate-limit bucket.
+func TestHostPolicyRefusesWildcardLabelUnderRemoteCatchAll(t *testing.T) {
+ e, _, _ := remoteHomedEdge(t, []string{mirrorHost}, remoteCatchAll)
+ e.issuer = func() (context.Context, bool) { return context.Background(), true }
+ for _, host := range []string{remoteWildcardLabel, remoteCatchAll, "stranger.example"} {
+ err := e.issuerHostPolicy()(context.Background(), host)
+ if err == nil {
+ t.Fatalf("%s: HostPolicy allowed an order", host)
+ }
+ if !strings.Contains(err.Error(), "not in registry") {
+ t.Fatalf("%s: HostPolicy = %v, want the unchanged not-in-registry refusal", host, err)
+ }
+ }
+}
+
+// Widening the NAMES a policy admits does not widen the BOXES that may order:
+// a non-issuer still refuses every name, remote-homed ones included.
+func TestHostPolicyStillGatesNonIssuer(t *testing.T) {
+ e, _, _ := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost)
+ e.issuer = func() (context.Context, bool) { return nil, false }
+ for _, host := range []string{remoteHomedHost, mirrorHost} {
+ if err := e.issuerHostPolicy()(context.Background(), host); !errors.Is(err, errNotIssuer) {
+ t.Fatalf("%s on a non-issuer = %v, want errNotIssuer", host, err)
+ }
+ }
+}
+
+// Single-box deployments are byte-for-byte unchanged: no home map armed (or an
+// empty one), so deploymentHost reduces to Allowed.
+func TestDeploymentHostIsAllowedOnASingleBoxDeployment(t *testing.T) {
+ e := testIssuerEdge(mirrorHost) // e.homes never stored: nil pointer
+ if !e.deploymentHost(mirrorHost) || e.deploymentHost("stranger.example") {
+ t.Fatal("with no home map at all, deploymentHost must be exactly Allowed")
+ }
+ e2, _, _ := remoteHomedEdge(t, []string{mirrorHost}) // map armed, folds to nothing
+ if !e2.deploymentHost(mirrorHost) || e2.deploymentHost("stranger.example") {
+ t.Fatal("with an empty home map, deploymentHost must be exactly Allowed")
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index affdadd91..7c712f448 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -767,6 +767,37 @@ func (e *Edge) Allowed(host string) bool {
return ok
}
+// deploymentHost reports whether host is served HERE (Allowed) or is exactly
+// named by another box's edges file (homeMap.KnownHost) — the admission bound
+// for the three cert-adjacent gates in certsync.go: issuerHostPolicy's order
+// gate and the HTTP-01 and tls-alpn-01 read-throughs.
+//
+// The widening exists because Allowed alone was the wrong bound the moment a
+// deployment grew a second edge (#277): DNS for a home-boxed route can point
+// at any edge, the backhaul then dials the home box with the real SNI, the
+// home box orders — and the CA validates against whichever edge DNS names,
+// which refused every challenge for a host outside its OWN registry. Five
+// such failures burn the name's Let's Encrypt per-hostname budget in minutes
+// while the route is down. Both halves are registry-backed and
+// deployment-controlled: the home map is folded from control/edges/<box>.json,
+// which each box writes from its own registry (internal/host's edgesFilePass),
+// so a stranger cannot add a name to it without a control-bucket write.
+//
+// EXACT only, on both halves: Allowed refuses the wildcard key and consults
+// the exact route map, KnownHost refuses "*." and skips Home()'s one-label
+// fallback. That is PR #180's rule, unchanged and merely widened from "this
+// box's routes" to "this deployment's exactly-named routes" — an
+// attacker-chosen label under a catch-all must never drive an ACME order or a
+// bucket read.
+//
+// Both halves take the same normalized form: serverName/requestHost lowercase
+// and punycode into the shape registry rows, and therefore edges files, are
+// written in. e.homes may hold nil before the map arms; KnownHost is
+// nil-receiver-safe, matching Home's pattern.
+func (e *Edge) deploymentHost(host string) bool {
+ return e.Allowed(host) || e.homes.Load().KnownHost(host)
+}
+
// lookupUpstream resolves host to its upstream entry — the request-routing
// lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy — see the comment
// there). An exact match (a real route, or a plain #151 alias landed in the