diff --git a/cmd/grove/trust_test.go b/cmd/grove/trust_test.go index 4d6548e..523ff71 100644 --- a/cmd/grove/trust_test.go +++ b/cmd/grove/trust_test.go @@ -22,7 +22,7 @@ func servePeer(t *testing.T, signer, offered *ca.CA, host string) string { t.Helper() p := proxy.New() p.ServeRoot(host, offered.RootPEM()) - iss := ca.NewIssuer(signer, "grov.site", p.Routes) + iss := ca.NewIssuer(signer, "grov.site", "", p.Routes) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) diff --git a/docs/content/concepts/https-and-dns.mdx b/docs/content/concepts/https-and-dns.mdx index 9cf2c2a..76840b7 100644 --- a/docs/content/concepts/https-and-dns.mdx +++ b/docs/content/concepts/https-and-dns.mdx @@ -5,7 +5,7 @@ description: A local root, leaves issued per hostname, real DNS pointing at loop ## The certificate authority -`grove install` generates a root in your state directory and installs it into the system trust stores, which is the step that may ask for your password. The daemon signs leaves from that root as they're asked for, so a new worktree needs nothing issued ahead of time. A hostname grove routes gets a leaf of its own, which is what lets it sit more than one label under `grov.site`, since a wildcard covers exactly one. Any other name gets a shared `*.grov.site` leaf, so a context that isn't running still reaches the page saying what to run, and a client offering made-up names can't make the daemon sign one leaf apiece. +`grove install` generates a root in your state directory and installs it into the system trust stores, which is the step that may ask for your password. The daemon signs leaves from that root as they're asked for, so a new worktree needs nothing issued ahead of time. A hostname grove routes gets a leaf of its own, which is what lets it sit more than one label under `grov.site`, since a wildcard covers exactly one. Any other name gets a shared wildcard, so a context that isn't running still reaches the page saying what to run, and a client offering made-up names can't make the daemon sign one leaf apiece. That's `*.grov.site`, or on a machine that [shares](/reference/cli/share), `*..grov.site`, since its names sit a label deeper than the first one reaches. Runtimes that ignore the OS store get told separately. `grove exec` sets `NODE_EXTRA_CA_CERTS` to the root, and `REQUESTS_CA_BUNDLE` and `DENO_CERT` to a bundle carrying it, since those two replace a trust store rather than adding to it and so need the whole thing. Where the system's own bundle was updated by the install, that's the one used; otherwise grove's. `grove doctor` reports which, and `GROVE_SYSTEM_BUNDLE` names one by hand. diff --git a/internal/ca/issuer.go b/internal/ca/issuer.go index 3366646..f8017df 100644 --- a/internal/ca/issuer.go +++ b/internal/ca/issuer.go @@ -14,6 +14,10 @@ import ( type Issuer struct { root *CA domain string + // What hostnames sit one label under: the domain, or . on a + // machine that shares. Its own wildcard covers that machine's unrouted + // names, which the domain's cannot reach two labels deep. + zone string routed func(name string) bool mu sync.Mutex @@ -24,10 +28,17 @@ type Issuer struct { // Only names routed asks about get a leaf of their own. Once the daemon answers // on the tailnet, minting for any name a client offers would let a peer fill // memory with one signed leaf per invented hostname. -func NewIssuer(root *CA, domain string, routed func(name string) bool) *Issuer { +// An empty zone means the domain itself. +func NewIssuer(root *CA, domain, zone string, routed func(name string) bool) *Issuer { + domain = strings.ToLower(domain) + zone = strings.ToLower(zone) + if zone == "" { + zone = domain + } return &Issuer{ root: root, - domain: strings.ToLower(domain), + domain: domain, + zone: zone, routed: routed, leaves: map[string]*tls.Certificate{}, } @@ -46,10 +57,19 @@ func (i *Issuer) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, e if name != i.domain && !strings.HasSuffix(name, "."+i.domain) && name != wildcard { return nil, fmt.Errorf("ca: %q is not under %s", name, i.domain) } - // An unrouted name still gets the wildcard, so one label deep it reaches the - // proxy's 503 page, which says what to run, rather than a handshake failure. - if name != wildcard && !i.routed(name) { - name = wildcard + // An unrouted name still gets a wildcard, so it reaches the proxy's 503 + // page, which says what to run, rather than a handshake failure. On a + // machine that shares, every name is two labels deep and only the zone's + // wildcard covers it. Two wildcards at most, so a peer offering invented + // names still costs nothing beyond them. + zoneWildcard := "*." + i.zone + if name != wildcard && name != zoneWildcard && !i.routed(name) { + sub, underZone := strings.CutSuffix(name, "."+i.zone) + if underZone && i.zone != i.domain && !strings.Contains(sub, ".") { + name = zoneWildcard + } else { + name = wildcard + } } i.mu.Lock() diff --git a/internal/ca/issuer_test.go b/internal/ca/issuer_test.go index d3b1252..1906a34 100644 --- a/internal/ca/issuer_test.go +++ b/internal/ca/issuer_test.go @@ -29,7 +29,7 @@ func issuer(t *testing.T) (*ca.Issuer, *x509.CertPool) { if !pool.AppendCertsFromPEM(root.RootPEM()) { t.Fatal("root not added to pool") } - return ca.NewIssuer(root, domain, everyName), pool + return ca.NewIssuer(root, domain, "", everyName), pool } func TestIssuerServesAnyDepthUnderTheDomain(t *testing.T) { @@ -87,7 +87,7 @@ func TestIssuerMintsOnlyForRoutedNames(t *testing.T) { t.Fatal(err) } const routed = "app.capella." + domain - iss := ca.NewIssuer(root, domain, func(name string) bool { return name == routed }) + iss := ca.NewIssuer(root, domain, "", func(name string) bool { return name == routed }) leaf, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: routed}) if err != nil { @@ -143,7 +143,7 @@ func TestMultiLevelHostnameHandshakesAndRoutes(t *testing.T) { } p := proxy.New() p.SetRoutes([]proxy.Route{{Host: host, Upstream: strings.TrimPrefix(upstream.URL, "http://")}}) - iss := ca.NewIssuer(root, domain, p.Routes) + iss := ca.NewIssuer(root, domain, "", p.Routes) go p.Serve(ln, iss.GetCertificate) t.Cleanup(func() { ln.Close() }) @@ -172,7 +172,7 @@ func BenchmarkIssueColdLeaf(b *testing.B) { if err != nil { b.Fatal(err) } - iss := ca.NewIssuer(root, domain, everyName) + iss := ca.NewIssuer(root, domain, "", everyName) b.ResetTimer() for i := 0; i < b.N; i++ { name := fmt.Sprintf("c%d.app.%s", i, domain) @@ -187,7 +187,7 @@ func BenchmarkIssueCachedLeaf(b *testing.B) { if err != nil { b.Fatal(err) } - iss := ca.NewIssuer(root, domain, everyName) + iss := ca.NewIssuer(root, domain, "", everyName) hello := &tls.ClientHelloInfo{ServerName: "app." + domain} if _, err := iss.GetCertificate(hello); err != nil { b.Fatal(err) @@ -199,3 +199,37 @@ func BenchmarkIssueCachedLeaf(b *testing.B) { } } } + +// On a machine that shares, every hostname is two labels deep, so the domain's +// wildcard covers none of them: an unrouted one, such as a stack not running +// since a reboot, showed a certificate error instead of the 503 page saying +// what to run. The zone's wildcard covers them, and minting stays bounded. +func TestIssuerCoversUnroutedNamesUnderTheZone(t *testing.T) { + root, err := ca.OpenOrCreate(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const zone = "regulus." + domain + iss := ca.NewIssuer(root, domain, zone, func(string) bool { return false }) + + for i := 0; i < 50; i++ { + name := fmt.Sprintf("x%d.%s", i, zone) + leaf, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: name}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := leaf.Leaf.VerifyHostname(name); err != nil { + t.Errorf("%s: unrouted name under the zone is not covered: %v", name, err) + } + } + // One label under the domain still gets the domain's wildcard, and deeper + // than the zone gets nothing new to sign. + for _, name := range []string{"app." + domain, "a.b." + zone, "x.other." + domain} { + if _, err := iss.GetCertificate(&tls.ClientHelloInfo{ServerName: name}); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + if got := iss.Minted(); got != 2 { + t.Errorf("minted %d leaves, want 2: the zone's wildcard and the domain's", got) + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 45a97ea..5dfe3bf 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -86,7 +86,7 @@ func New(cfg Config) (*Server, error) { } prx := proxy.New() prx.ServeRoot(zoneOr(cfg.Zone, cfg.Domain), root.RootPEM()) - issuer := ca.NewIssuer(root, cfg.Domain, prx.Routes) + issuer := ca.NewIssuer(root, cfg.Domain, cfg.Zone, prx.Routes) // Beside the authority, so a daemon pointed elsewhere keeps its allocations // there too. memory, err := lease.OpenMemory(filepath.Join(cfg.CADir, "ports.json"))