diff --git a/main.go b/main.go index 1b0e641..29ba524 100644 --- a/main.go +++ b/main.go @@ -22,8 +22,9 @@ // Validity: 90 days. Re-run before expiry; Caddy reloads // automatically on file change. // -// pilot-ca verify -// Confirm a leaf cert chains to the root and is currently valid. +// pilot-ca verify [hostname] +// Confirm a leaf cert chains to the root, is currently valid, +// and (when hostname is given) that the leaf SAN matches hostname. // Exit 0 on success. // // The root cert (PEM) is what gets embedded in pilot-daemon via @@ -87,7 +88,7 @@ func main() { fmt.Fprintln(os.Stderr, "usage: pilot-ca [args...]") fmt.Fprintln(os.Stderr, " init-root ") fmt.Fprintln(os.Stderr, " issue-beacon ") - fmt.Fprintln(os.Stderr, " verify ") + fmt.Fprintln(os.Stderr, " verify [hostname]") } flag.Parse() args := flag.Args() @@ -113,11 +114,16 @@ func main() { die("issue-beacon: %v", err) } case "verify": - if len(args) != 3 { + hostname := "" + if len(args) == 3 { + // verify — chain-only (backward compat) + } else if len(args) == 4 { + hostname = args[3] + } else { flag.Usage() os.Exit(2) } - if err := verifyChain(args[1], args[2]); err != nil { + if err := verifyChain(args[1], args[2], hostname); err != nil { die("verify: %v", err) } default: @@ -241,10 +247,11 @@ func issueBeacon(rootDir, hostname, outDir string) error { } // verifyChain confirms a leaf cert chains to a root cert and is valid -// for the current wall clock. Used in CI before bundling a leaf into -// a beacon deployment, and as a sanity check in install/upgrade -// scripts. -func verifyChain(rootCrtPath, leafCrtPath string) error { +// for the current wall clock. When hostname is non-empty, the leaf's +// SAN is also checked against hostname via x509.VerifyOptions.DNSName. +// Used in CI before bundling a leaf into a beacon deployment, and as a +// sanity check in install/upgrade scripts. +func verifyChain(rootCrtPath, leafCrtPath, hostname string) error { rootPEM, err := os.ReadFile(rootCrtPath) if err != nil { return fmt.Errorf("read root cert: %w", err) @@ -265,14 +272,22 @@ func verifyChain(rootCrtPath, leafCrtPath string) error { if err != nil { return fmt.Errorf("leaf parse: %w", err) } - if _, err := leaf.Verify(x509.VerifyOptions{ + opts := x509.VerifyOptions{ Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - }); err != nil { + } + if hostname != "" { + opts.DNSName = hostname + } + if _, err := leaf.Verify(opts); err != nil { return fmt.Errorf("verify failed: %w", err) } fmt.Printf("OK — %s chains to %s\n", leafCrtPath, rootCrtPath) - fmt.Printf(" CN=%s not-after=%s\n", leaf.Subject.CommonName, leaf.NotAfter.Format(time.RFC3339)) + if hostname != "" { + fmt.Printf(" hostname=%s CN=%s not-after=%s\n", hostname, leaf.Subject.CommonName, leaf.NotAfter.Format(time.RFC3339)) + } else { + fmt.Printf(" CN=%s not-after=%s\n", leaf.Subject.CommonName, leaf.NotAfter.Format(time.RFC3339)) + } return nil } diff --git a/zz_branches_test.go b/zz_branches_test.go index e22e284..18e6c40 100644 --- a/zz_branches_test.go +++ b/zz_branches_test.go @@ -76,7 +76,7 @@ func TestVerifyChain_MissingLeafFile(t *testing.T) { if err := initRoot(rootDir); err != nil { t.Fatalf("initRoot: %v", err) } - err := verifyChain(filepath.Join(rootDir, "root.crt"), "/nonexistent/leaf.crt") + err := verifyChain(filepath.Join(rootDir, "root.crt"), "/nonexistent/leaf.crt", "") if err == nil || !strings.Contains(err.Error(), "read leaf cert") { t.Errorf("err = %v; want 'read leaf cert'", err) } @@ -95,7 +95,7 @@ func TestVerifyChain_RootPEMAppendFails(t *testing.T) { if err := os.WriteFile(leafPath, []byte("garbage"), 0o644); err != nil { t.Fatalf("write leaf: %v", err) } - err := verifyChain(rootPath, leafPath) + err := verifyChain(rootPath, leafPath, "") if err == nil || !strings.Contains(err.Error(), "root cert PEM parse failed") { t.Errorf("err = %v; want 'root cert PEM parse failed'", err) } @@ -114,7 +114,7 @@ func TestVerifyChain_LeafParseError(t *testing.T) { if err := os.WriteFile(leafPath, []byte(garbageLeaf), 0o644); err != nil { t.Fatalf("write leaf: %v", err) } - err := verifyChain(filepath.Join(rootDir, "root.crt"), leafPath) + err := verifyChain(filepath.Join(rootDir, "root.crt"), leafPath, "") if err == nil || !strings.Contains(err.Error(), "leaf parse") { t.Errorf("err = %v; want 'leaf parse'", err) } diff --git a/zz_pilot_ca_test.go b/zz_pilot_ca_test.go index c7ff396..bbff476 100644 --- a/zz_pilot_ca_test.go +++ b/zz_pilot_ca_test.go @@ -198,7 +198,7 @@ func TestVerifyChain_AcceptsValid(t *testing.T) { if err := issueBeacon(rootDir, host, leafDir); err != nil { t.Fatalf("issueBeacon: %v", err) } - if err := verifyChain(filepath.Join(rootDir, "root.crt"), filepath.Join(leafDir, host+".crt")); err != nil { + if err := verifyChain(filepath.Join(rootDir, "root.crt"), filepath.Join(leafDir, host+".crt"), host); err != nil { t.Errorf("verifyChain rejected valid chain: %v", err) } } @@ -223,7 +223,7 @@ func TestVerifyChain_RejectsUnrelatedRoot(t *testing.T) { t.Fatalf("issueBeacon: %v", err) } // Leaf was signed by rootA, but we verify against rootB. - err := verifyChain(filepath.Join(rootB, "root.crt"), filepath.Join(leafDir, host+".crt")) + err := verifyChain(filepath.Join(rootB, "root.crt"), filepath.Join(leafDir, host+".crt"), host) if err == nil { t.Fatal("verifyChain accepted leaf signed by a different root; expected rejection") } @@ -237,7 +237,7 @@ func TestVerifyChain_RejectsBadPEM(t *testing.T) { if err := os.WriteFile(junk, []byte("not a pem"), 0o644); err != nil { t.Fatalf("seed junk: %v", err) } - err := verifyChain(junk, junk) + err := verifyChain(junk, junk, "") if err == nil { t.Fatal("verifyChain accepted garbage PEM; expected rejection") }