Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/grove/trust_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/content/concepts/https-and-dns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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), `*.<host>.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.

Expand Down
32 changes: 26 additions & 6 deletions internal/ca/issuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
type Issuer struct {
root *CA
domain string
// What hostnames sit one label under: the domain, or <host>.<domain> 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
Expand All @@ -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{},
}
Expand All @@ -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()
Expand Down
44 changes: 39 additions & 5 deletions internal/ca/issuer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() })

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Loading