detached 30b9539

security(pki): harden internal CA and onboarding endpoints

- Fix XSS via r.Host in onboarding page (html.EscapeString)
- Fix XML injection in mobileconfig via rootCN escaping
- Atomic file writes in writeSecure (temp+chmod+rename)
- Harden loadOrGenerateRoot: reject corrupt/mismatched keys, only
  bootstrap when both files missing
- Validate intermediate chain signature (CheckSignatureFrom)
- Add domain validation in IssueCertificate (no wildcards/IPs)
- Zero leaf key material after use (clear builtin)
- Add renewal mutex to serialize RenewIntermediate
- Move onboarding routes from HTTP to TLS port (Tailnet clients)
- Add SecurityHeaders to HTTP middleware chain
- Move RouteChecker to boundary interface
- Move isDomainAllowed from cache-hit hot path to periodic sweep
- Add LeafLifetime/IntermediateLifetime to CA boundary interface
- CLI ca commands: resolve data dir from config, add --json flag
- Wrap trust store errors with context
commit
30b9539374ced4878fb8b5305311431733795a37
author
bnema · · 2026-03-29 20:55 UTC
parent
6cb035c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +280 -102
M internal/adapters/in/cli/ca.go +58 -14
@@ -1,6 +1,7 @@
11 package cli
22
33 import (
4+ "context"
45 "crypto/x509"
56 "encoding/pem"
67 "fmt"
@@ -13,7 +14,6 @@ import (
1314 "github.com/spf13/cobra"
1415
1516 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16- "github.com/bnema/gordon/internal/app"
1717 )
1818
1919 func newCACmd() *cobra.Command {
@@ -37,7 +37,11 @@ func newCAExportCmd() *cobra.Command {
3737 Short: "Export the root CA certificate",
3838 Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.",
3939 RunE: func(cmd *cobra.Command, _ []string) error {
40- return runCAExport(cmd.OutOrStdout(), outPath)
40+ dataDir, err := resolveCADataDir()
41+ if err != nil {
42+ return err
43+ }
44+ return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath)
4145 },
4246 }
4347
@@ -47,34 +51,59 @@ func newCAExportCmd() *cobra.Command {
4751 }
4852
4953 func newCAInstallCmd() *cobra.Command {
50- var uninstall bool
54+ var (
55+ uninstall bool
56+ jsonOut bool
57+ )
5158
5259 cmd := &cobra.Command{
5360 Use: "install",
5461 Short: "Install/uninstall the root CA in the system trust store",
5562 Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.",
5663 RunE: func(cmd *cobra.Command, _ []string) error {
57- return runCAInstall(cmd.OutOrStdout(), uninstall)
64+ dataDir, err := resolveCADataDir()
65+ if err != nil {
66+ return err
67+ }
68+ return runCAInstall(cmd.Context(), cmd.OutOrStdout(), dataDir, uninstall, jsonOut)
5869 },
5970 }
6071
6172 cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")
73+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
6274
6375 return cmd
6476 }
6577
6678 func newCAInfoCmd() *cobra.Command {
67- return &cobra.Command{
79+ var jsonOut bool
80+
81+ cmd := &cobra.Command{
6882 Use: "info",
6983 Short: "Show CA status information",
7084 RunE: func(cmd *cobra.Command, _ []string) error {
71- return runCAInfo(cmd.OutOrStdout())
85+ dataDir, err := resolveCADataDir()
86+ if err != nil {
87+ return err
88+ }
89+ return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut)
7290 },
7391 }
92+
93+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
94+
95+ return cmd
7496 }
7597
76-func loadCAFromDataDir() (*pkiadapter.CA, error) {
77- dataDir := app.DefaultDataDir()
98+func resolveCADataDir() (string, error) {
99+ local, err := GetLocalServices(configPath)
100+ if err != nil {
101+ return "", err
102+ }
103+ return local.GetDataDir(), nil
104+}
105+
106+func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) {
78107 log := zerowrap.Default()
79108 ca, err := pkiadapter.NewCA(dataDir, log)
80109 if err != nil {
@@ -83,8 +112,8 @@ func loadCAFromDataDir() (*pkiadapter.CA, error) {
83112 return ca, nil
84113 }
85114
86-func runCAExport(out io.Writer, outPath string) error {
87- ca, err := loadCAFromDataDir()
115+func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error {
116+ ca, err := loadCAFromDataDir(dataDir)
88117 if err != nil {
89118 return err
90119 }
@@ -105,8 +134,8 @@ func runCAExport(out io.Writer, outPath string) error {
105134 return err
106135 }
107136
108-func runCAInstall(out io.Writer, uninstall bool) error {
109- ca, err := loadCAFromDataDir()
137+func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, jsonOut bool) error {
138+ ca, err := loadCAFromDataDir(dataDir)
110139 if err != nil {
111140 return err
112141 }
@@ -125,17 +154,23 @@ func runCAInstall(out io.Writer, uninstall bool) error {
125154 if err := pkiadapter.UninstallRoot(cert); err != nil {
126155 return fmt.Errorf("failed to uninstall root CA: %w", err)
127156 }
157+ if jsonOut {
158+ return writeJSON(out, map[string]string{"status": "uninstalled"})
159+ }
128160 return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
129161 }
130162
131163 if err := pkiadapter.InstallRoot(cert); err != nil {
132164 return fmt.Errorf("failed to install root CA: %w", err)
133165 }
166+ if jsonOut {
167+ return writeJSON(out, map[string]string{"status": "installed"})
168+ }
134169 return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
135170 }
136171
137-func runCAInfo(out io.Writer) error {
138- ca, err := loadCAFromDataDir()
172+func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error {
173+ ca, err := loadCAFromDataDir(dataDir)
139174 if err != nil {
140175 return err
141176 }
@@ -143,6 +178,15 @@ func runCAInfo(out io.Writer) error {
143178 interExpiry := ca.IntermediateExpiresAt()
144179 remaining := time.Until(interExpiry).Truncate(time.Minute)
145180
181+ if jsonOut {
182+ return writeJSON(out, map[string]any{
183+ "root_cn": ca.RootCommonName(),
184+ "fingerprint": "SHA256:" + ca.RootFingerprint(),
185+ "intermediate_expiry": interExpiry.Format(time.RFC3339),
186+ "intermediate_ttl": remaining.String(),
187+ })
188+ }
189+
146190 if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {
147191 return err
148192 }
@@ -1,6 +1,7 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
4 "crypto/x509"5 "crypto/x509"
5 "encoding/pem"6 "encoding/pem"
6 "fmt"7 "fmt"
@@ -13,7 +14,6 @@ import (
13 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
14 15
15 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"16 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16- "github.com/bnema/gordon/internal/app"
17 )17 )
18 18
19 func newCACmd() *cobra.Command {19 func newCACmd() *cobra.Command {
@@ -37,7 +37,11 @@ func newCAExportCmd() *cobra.Command {
37 Short: "Export the root CA certificate",37 Short: "Export the root CA certificate",
38 Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.",38 Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.",
39 RunE: func(cmd *cobra.Command, _ []string) error {39 RunE: func(cmd *cobra.Command, _ []string) error {
40- return runCAExport(cmd.OutOrStdout(), outPath)40+ dataDir, err := resolveCADataDir()
41+ if err != nil {
42+ return err
43+ }
44+ return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath)
41 },45 },
42 }46 }
43 47
@@ -47,34 +51,59 @@ func newCAExportCmd() *cobra.Command {
47 }51 }
48 52
49 func newCAInstallCmd() *cobra.Command {53 func newCAInstallCmd() *cobra.Command {
50- var uninstall bool54+ var (
55+ uninstall bool
56+ jsonOut bool
57+ )
51 58
52 cmd := &cobra.Command{59 cmd := &cobra.Command{
53 Use: "install",60 Use: "install",
54 Short: "Install/uninstall the root CA in the system trust store",61 Short: "Install/uninstall the root CA in the system trust store",
55 Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.",62 Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.",
56 RunE: func(cmd *cobra.Command, _ []string) error {63 RunE: func(cmd *cobra.Command, _ []string) error {
57- return runCAInstall(cmd.OutOrStdout(), uninstall)64+ dataDir, err := resolveCADataDir()
65+ if err != nil {
66+ return err
67+ }
68+ return runCAInstall(cmd.Context(), cmd.OutOrStdout(), dataDir, uninstall, jsonOut)
58 },69 },
59 }70 }
60 71
61 cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")72 cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing")
73+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
62 74
63 return cmd75 return cmd
64 }76 }
65 77
66 func newCAInfoCmd() *cobra.Command {78 func newCAInfoCmd() *cobra.Command {
67- return &cobra.Command{79+ var jsonOut bool
80+
81+ cmd := &cobra.Command{
68 Use: "info",82 Use: "info",
69 Short: "Show CA status information",83 Short: "Show CA status information",
70 RunE: func(cmd *cobra.Command, _ []string) error {84 RunE: func(cmd *cobra.Command, _ []string) error {
71- return runCAInfo(cmd.OutOrStdout())85+ dataDir, err := resolveCADataDir()
86+ if err != nil {
87+ return err
88+ }
89+ return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut)
72 },90 },
73 }91 }
92+
93+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
94+
95+ return cmd
74 }96 }
75 97
76-func loadCAFromDataDir() (*pkiadapter.CA, error) {98+func resolveCADataDir() (string, error) {
77- dataDir := app.DefaultDataDir()99+ local, err := GetLocalServices(configPath)
100+ if err != nil {
101+ return "", err
102+ }
103+ return local.GetDataDir(), nil
104+}
105+
106+func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) {
78 log := zerowrap.Default()107 log := zerowrap.Default()
79 ca, err := pkiadapter.NewCA(dataDir, log)108 ca, err := pkiadapter.NewCA(dataDir, log)
80 if err != nil {109 if err != nil {
@@ -83,8 +112,8 @@ func loadCAFromDataDir() (*pkiadapter.CA, error) {
83 return ca, nil112 return ca, nil
84 }113 }
85 114
86-func runCAExport(out io.Writer, outPath string) error {115+func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error {
87- ca, err := loadCAFromDataDir()116+ ca, err := loadCAFromDataDir(dataDir)
88 if err != nil {117 if err != nil {
89 return err118 return err
90 }119 }
@@ -105,8 +134,8 @@ func runCAExport(out io.Writer, outPath string) error {
105 return err134 return err
106 }135 }
107 136
108-func runCAInstall(out io.Writer, uninstall bool) error {137+func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, jsonOut bool) error {
109- ca, err := loadCAFromDataDir()138+ ca, err := loadCAFromDataDir(dataDir)
110 if err != nil {139 if err != nil {
111 return err140 return err
112 }141 }
@@ -125,17 +154,23 @@ func runCAInstall(out io.Writer, uninstall bool) error {
125 if err := pkiadapter.UninstallRoot(cert); err != nil {154 if err := pkiadapter.UninstallRoot(cert); err != nil {
126 return fmt.Errorf("failed to uninstall root CA: %w", err)155 return fmt.Errorf("failed to uninstall root CA: %w", err)
127 }156 }
157+ if jsonOut {
158+ return writeJSON(out, map[string]string{"status": "uninstalled"})
159+ }
128 return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))160 return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
129 }161 }
130 162
131 if err := pkiadapter.InstallRoot(cert); err != nil {163 if err := pkiadapter.InstallRoot(cert); err != nil {
132 return fmt.Errorf("failed to install root CA: %w", err)164 return fmt.Errorf("failed to install root CA: %w", err)
133 }165 }
166+ if jsonOut {
167+ return writeJSON(out, map[string]string{"status": "installed"})
168+ }
134 return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))169 return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
135 }170 }
136 171
137-func runCAInfo(out io.Writer) error {172+func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error {
138- ca, err := loadCAFromDataDir()173+ ca, err := loadCAFromDataDir(dataDir)
139 if err != nil {174 if err != nil {
140 return err175 return err
141 }176 }
@@ -143,6 +178,15 @@ func runCAInfo(out io.Writer) error {
143 interExpiry := ca.IntermediateExpiresAt()178 interExpiry := ca.IntermediateExpiresAt()
144 remaining := time.Until(interExpiry).Truncate(time.Minute)179 remaining := time.Until(interExpiry).Truncate(time.Minute)
145 180
181+ if jsonOut {
182+ return writeJSON(out, map[string]any{
183+ "root_cn": ca.RootCommonName(),
184+ "fingerprint": "SHA256:" + ca.RootFingerprint(),
185+ "intermediate_expiry": interExpiry.Format(time.RFC3339),
186+ "intermediate_ttl": remaining.String(),
187+ })
188+ }
189+
146 if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {190 if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {
147 return err191 return err
148 }192 }
M internal/adapters/in/http/onboarding/handler.go +22 -18
@@ -2,25 +2,27 @@ package onboarding
22
33 import (
44 "fmt"
5+ "html"
56 "net"
67 "net/http"
78
89 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
910 )
1011
11-// Handler serves CA onboarding endpoints on the HTTP port.
12+// Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.
1213 type Handler struct {
1314 rootPEM []byte
1415 mobileconfig []byte
15- cidrs []*net.IPNet
16+ tlsPort int
1617 }
1718
1819 // NewHandler creates an onboarding handler.
19-func NewHandler(rootPEM, rootDER []byte, rootCN string, cloudflareCIDRs []*net.IPNet) *Handler {
20+// tlsPort is the HTTPS port used to build redirect URLs on the onboarding page.
21+func NewHandler(rootPEM, rootDER []byte, rootCN string, tlsPort int) *Handler {
2022 return &Handler{
2123 rootPEM: rootPEM,
2224 mobileconfig: pkiadapter.GenerateMobileconfig(rootDER, rootCN),
23- cidrs: cloudflareCIDRs,
25+ tlsPort: tlsPort,
2426 }
2527 }
2628
@@ -38,13 +40,25 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
3840 _, _ = w.Write(h.mobileconfig)
3941 }
4042
43+// normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port.
44+// It strips any existing port, applies the configured TLS port (omitting :443), and
45+// HTML-escapes the result to prevent XSS via a crafted Host header.
46+func (h *Handler) normalizeHTTPSURL(r *http.Request) string {
47+ hostname := r.Host
48+ if host, _, err := net.SplitHostPort(hostname); err == nil {
49+ hostname = host
50+ }
51+ if h.tlsPort != 0 && h.tlsPort != 443 {
52+ hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)
53+ }
54+ return html.EscapeString(fmt.Sprintf("https://%s/", hostname))
55+}
56+
4157 // ServeOnboardingPage serves the CA trust onboarding HTML page.
4258 func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
4359 w.Header().Set("Content-Type", "text/html; charset=utf-8")
4460
45- scheme := "https"
46- host := r.Host
47- redirectURL := fmt.Sprintf("%s://%s/", scheme, host)
61+ redirectURL := h.normalizeHTTPSURL(r)
4862
4963 _, _ = fmt.Fprintf(w, `<!DOCTYPE html>
5064 <html lang="en">
@@ -87,18 +101,8 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
87101 </div>
88102
89103 <div class="skip">
90- <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax'">Go to site →</a></p>
104+ <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax;Secure'">Go to site →</a></p>
91105 </div>
92106 </body>
93107 </html>`, redirectURL)
94108 }
95-
96-// IsCloudflareIP checks if the given IP is in Cloudflare's CIDR ranges.
97-func (h *Handler) IsCloudflareIP(ip net.IP) bool {
98- for _, cidr := range h.cidrs {
99- if cidr.Contains(ip) {
100- return true
101- }
102- }
103- return false
104-}
@@ -2,25 +2,27 @@ package onboarding
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "html"
5 "net"6 "net"
6 "net/http"7 "net/http"
7 8
8 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"9 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
9 )10 )
10 11
11-// Handler serves CA onboarding endpoints on the HTTP port.12+// Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.
12 type Handler struct {13 type Handler struct {
13 rootPEM []byte14 rootPEM []byte
14 mobileconfig []byte15 mobileconfig []byte
15- cidrs []*net.IPNet16+ tlsPort int
16 }17 }
17 18
18 // NewHandler creates an onboarding handler.19 // NewHandler creates an onboarding handler.
19-func NewHandler(rootPEM, rootDER []byte, rootCN string, cloudflareCIDRs []*net.IPNet) *Handler {20+// tlsPort is the HTTPS port used to build redirect URLs on the onboarding page.
21+func NewHandler(rootPEM, rootDER []byte, rootCN string, tlsPort int) *Handler {
20 return &Handler{22 return &Handler{
21 rootPEM: rootPEM,23 rootPEM: rootPEM,
22 mobileconfig: pkiadapter.GenerateMobileconfig(rootDER, rootCN),24 mobileconfig: pkiadapter.GenerateMobileconfig(rootDER, rootCN),
23- cidrs: cloudflareCIDRs,25+ tlsPort: tlsPort,
24 }26 }
25 }27 }
26 28
@@ -38,13 +40,25 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
38 _, _ = w.Write(h.mobileconfig)40 _, _ = w.Write(h.mobileconfig)
39 }41 }
40 42
43+// normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port.
44+// It strips any existing port, applies the configured TLS port (omitting :443), and
45+// HTML-escapes the result to prevent XSS via a crafted Host header.
46+func (h *Handler) normalizeHTTPSURL(r *http.Request) string {
47+ hostname := r.Host
48+ if host, _, err := net.SplitHostPort(hostname); err == nil {
49+ hostname = host
50+ }
51+ if h.tlsPort != 0 && h.tlsPort != 443 {
52+ hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)
53+ }
54+ return html.EscapeString(fmt.Sprintf("https://%s/", hostname))
55+}
56+
41 // ServeOnboardingPage serves the CA trust onboarding HTML page.57 // ServeOnboardingPage serves the CA trust onboarding HTML page.
42 func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {58 func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
43 w.Header().Set("Content-Type", "text/html; charset=utf-8")59 w.Header().Set("Content-Type", "text/html; charset=utf-8")
44 60
45- scheme := "https"61+ redirectURL := h.normalizeHTTPSURL(r)
46- host := r.Host
47- redirectURL := fmt.Sprintf("%s://%s/", scheme, host)
48 62
49 _, _ = fmt.Fprintf(w, `<!DOCTYPE html>63 _, _ = fmt.Fprintf(w, `<!DOCTYPE html>
50 <html lang="en">64 <html lang="en">
@@ -87,18 +101,8 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
87 </div>101 </div>
88 102
89 <div class="skip">103 <div class="skip">
90- <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax'">Go to site →</a></p>104+ <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax;Secure'">Go to site →</a></p>
91 </div>105 </div>
92 </body>106 </body>
93 </html>`, redirectURL)107 </html>`, redirectURL)
94 }108 }
95-
96-// IsCloudflareIP checks if the given IP is in Cloudflare's CIDR ranges.
97-func (h *Handler) IsCloudflareIP(ip net.IP) bool {
98- for _, cidr := range h.cidrs {
99- if cidr.Contains(ip) {
100- return true
101- }
102- }
103- return false
104-}
M internal/adapters/in/http/onboarding/handler_test.go +3 -3
@@ -13,7 +13,7 @@ import (
1313 func TestHandler_CACert(t *testing.T) {
1414 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
1515 rootDER := []byte("fakeDER")
16- h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
16+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", 443)
1717
1818 req := httptest.NewRequest("GET", "/ca.crt", nil)
1919 w := httptest.NewRecorder()
@@ -27,7 +27,7 @@ func TestHandler_CACert(t *testing.T) {
2727 func TestHandler_Mobileconfig(t *testing.T) {
2828 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
2929 rootDER := []byte("fakeDER")
30- h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
30+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", 443)
3131
3232 req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)
3333 w := httptest.NewRecorder()
@@ -41,7 +41,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
4141 func TestHandler_OnboardingPage(t *testing.T) {
4242 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
4343 rootDER := []byte("fakeDER")
44- h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
44+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", 443)
4545
4646 req := httptest.NewRequest("GET", "/ca", nil)
4747 w := httptest.NewRecorder()
@@ -13,7 +13,7 @@ import (
13 func TestHandler_CACert(t *testing.T) {13 func TestHandler_CACert(t *testing.T) {
14 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")14 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
15 rootDER := []byte("fakeDER")15 rootDER := []byte("fakeDER")
16- h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)16+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", 443)
17 17
18 req := httptest.NewRequest("GET", "/ca.crt", nil)18 req := httptest.NewRequest("GET", "/ca.crt", nil)
19 w := httptest.NewRecorder()19 w := httptest.NewRecorder()
@@ -27,7 +27,7 @@ func TestHandler_CACert(t *testing.T) {
27 func TestHandler_Mobileconfig(t *testing.T) {27 func TestHandler_Mobileconfig(t *testing.T) {
28 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")28 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
29 rootDER := []byte("fakeDER")29 rootDER := []byte("fakeDER")
30- h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)30+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", 443)
31 31
32 req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)32 req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)
33 w := httptest.NewRecorder()33 w := httptest.NewRecorder()
@@ -41,7 +41,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
41 func TestHandler_OnboardingPage(t *testing.T) {41 func TestHandler_OnboardingPage(t *testing.T) {
42 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")42 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
43 rootDER := []byte("fakeDER")43 rootDER := []byte("fakeDER")
44- h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)44+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", 443)
45 45
46 req := httptest.NewRequest("GET", "/ca", nil)46 req := httptest.NewRequest("GET", "/ca", nil)
47 w := httptest.NewRecorder()47 w := httptest.NewRecorder()
M internal/adapters/out/pki/ca.go +121 -17
@@ -11,8 +11,10 @@ import (
1111 "crypto/x509"
1212 "crypto/x509/pkix"
1313 "encoding/pem"
14+ "errors"
1415 "fmt"
1516 "math/big"
17+ "net"
1618 "os"
1719 "path/filepath"
1820 "strings"
@@ -48,6 +50,8 @@ type CA struct {
4850 mu sync.RWMutex
4951 interCert *x509.Certificate
5052 interKey crypto.Signer
53+
54+ renewMu sync.Mutex // serialises intermediate renewal
5155 }
5256
5357 // SetAIAURL sets the Authority Information Access URL embedded in leaf certs.
@@ -109,6 +113,9 @@ func (ca *CA) IntermediateExpiresAt() time.Time {
109113 // RenewIntermediate regenerates the intermediate CA certificate,
110114 // signed by the root CA. Existing leaf certs remain valid.
111115 func (ca *CA) RenewIntermediate() error {
116+ ca.renewMu.Lock()
117+ defer ca.renewMu.Unlock()
118+
112119 cert, key, err := ca.generateIntermediate()
113120 if err != nil {
114121 return err
@@ -131,18 +138,39 @@ func (ca *CA) loadOrGenerateRoot() error {
131138 certPEM, certErr := os.ReadFile(certPath)
132139 keyPEM, keyErr := os.ReadFile(keyPath)
133140
134- if certErr == nil && keyErr == nil {
141+ certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
142+ keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
143+
144+ switch {
145+ case certErr == nil && keyErr == nil:
135146 cert, key, err := parseCertAndKey(certPEM, keyPEM)
136- if err == nil {
137- ca.rootCert = cert
138- ca.rootKey = key
139- ca.rootPEM = certPEM
140- ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
141- return nil
147+ if err != nil {
148+ return fmt.Errorf("corrupt root CA files: %w", err)
142149 }
143- ca.log.Warn().Err(err).Msg("failed to parse existing root CA, regenerating")
150+ if err := verifyKeyPair(cert, key); err != nil {
151+ return fmt.Errorf("root CA cert/key mismatch: %w", err)
152+ }
153+ ca.rootCert = cert
154+ ca.rootKey = key
155+ ca.rootPEM = certPEM
156+ ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
157+ return nil
158+
159+ case certMissing && keyMissing:
160+ return ca.bootstrapRoot(certPath, keyPath)
161+
162+ case certMissing || keyMissing:
163+ return fmt.Errorf("incomplete root CA: cert missing=%v, key missing=%v", certMissing, keyMissing)
164+
165+ default:
166+ if certErr != nil {
167+ return fmt.Errorf("read root cert: %w", certErr)
168+ }
169+ return fmt.Errorf("read root key: %w", keyErr)
144170 }
171+}
145172
173+func (ca *CA) bootstrapRoot(certPath, keyPath string) error {
146174 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
147175 if err != nil {
148176 return fmt.Errorf("generate root key: %w", err)
@@ -178,12 +206,12 @@ func (ca *CA) loadOrGenerateRoot() error {
178206 return fmt.Errorf("parse root cert: %w", err)
179207 }
180208
181- certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
209+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
182210 keyDER, err := x509.MarshalECPrivateKey(key)
183211 if err != nil {
184212 return fmt.Errorf("marshal root key: %w", err)
185213 }
186- keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
214+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
187215
188216 if err := writeSecure(certPath, certPEM, 0644); err != nil {
189217 return err
@@ -208,7 +236,13 @@ func (ca *CA) loadOrGenerateIntermediate() error {
208236
209237 if certErr == nil && keyErr == nil {
210238 cert, key, err := parseCertAndKey(certPEM, keyPEM)
211- if err == nil && time.Now().Before(cert.NotAfter) {
239+ if err != nil {
240+ ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
241+ } else if err := verifyKeyPair(cert, key); err != nil {
242+ ca.log.Warn().Err(err).Msg("intermediate CA cert/key mismatch, regenerating")
243+ } else if err := cert.CheckSignatureFrom(ca.rootCert); err != nil {
244+ ca.log.Warn().Err(err).Msg("intermediate CA not signed by current root, regenerating")
245+ } else if time.Now().Before(cert.NotAfter) {
212246 ca.interCert = cert
213247 ca.interKey = key
214248 ca.log.Info().
@@ -216,9 +250,6 @@ func (ca *CA) loadOrGenerateIntermediate() error {
216250 Time("expires", cert.NotAfter).
217251 Msg("loaded existing intermediate CA")
218252 return nil
219- }
220- if err != nil {
221- ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
222253 } else {
223254 ca.log.Warn().Msg("intermediate CA expired, regenerating")
224255 }
@@ -300,6 +331,10 @@ func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) e
300331 // IssueCertificate generates a leaf cert for the given domain,
301332 // signed by the current intermediate CA.
302333 func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
334+ if err := validateDomain(domain); err != nil {
335+ return nil, fmt.Errorf("invalid domain: %w", err)
336+ }
337+
303338 ca.mu.RLock()
304339 interCert := ca.interCert
305340 interKey := ca.interKey
@@ -348,6 +383,8 @@ func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
348383 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
349384
350385 tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)
386+ clear(keyDER)
387+ clear(keyPEM)
351388 if err != nil {
352389 return nil, fmt.Errorf("create TLS cert: %w", err)
353390 }
@@ -360,6 +397,11 @@ func (ca *CA) LeafLifetime() time.Duration {
360397 return leafLifetime
361398 }
362399
400+// IntermediateLifetime returns the configured intermediate CA lifetime.
401+func (ca *CA) IntermediateLifetime() time.Duration {
402+ return intermediateLifetime
403+}
404+
363405 func randomSerial() (*big.Int, error) {
364406 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
365407 if err != nil {
@@ -390,9 +432,71 @@ func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer,
390432 return cert, key, nil
391433 }
392434
393-func writeSecure(path string, data []byte, perm os.FileMode) error {
394- if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
435+func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) {
436+ dir := filepath.Dir(path)
437+ if err := os.MkdirAll(dir, 0700); err != nil {
395438 return fmt.Errorf("create dir for %s: %w", path, err)
396439 }
397- return os.WriteFile(path, data, perm)
440+ // Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions.
441+ tmp, err := os.CreateTemp(dir, ".tmp-*")
442+ if err != nil {
443+ return fmt.Errorf("create temp file for %s: %w", path, err)
444+ }
445+ tmpName := tmp.Name()
446+ defer func() {
447+ if retErr != nil {
448+ os.Remove(tmpName)
449+ }
450+ }()
451+ if _, err := tmp.Write(data); err != nil {
452+ tmp.Close()
453+ return fmt.Errorf("write temp file for %s: %w", path, err)
454+ }
455+ if err := tmp.Close(); err != nil {
456+ return fmt.Errorf("close temp file for %s: %w", path, err)
457+ }
458+ if err := os.Chmod(tmpName, perm); err != nil {
459+ return fmt.Errorf("chmod temp file for %s: %w", path, err)
460+ }
461+ if err := os.Rename(tmpName, path); err != nil {
462+ return fmt.Errorf("rename temp file to %s: %w", path, err)
463+ }
464+ return nil
465+}
466+
467+// validateDomain performs defense-in-depth validation of a domain name
468+// before issuing a certificate.
469+func validateDomain(domain string) error {
470+ if domain == "" {
471+ return fmt.Errorf("empty domain")
472+ }
473+ if len(domain) > 253 {
474+ return fmt.Errorf("domain too long (%d chars)", len(domain))
475+ }
476+ if strings.HasPrefix(domain, "*") {
477+ return fmt.Errorf("wildcard domains not allowed")
478+ }
479+ if strings.ContainsAny(domain, "/\\") {
480+ return fmt.Errorf("domain contains path separator")
481+ }
482+ if net.ParseIP(domain) != nil {
483+ return fmt.Errorf("IP addresses not allowed, must be a hostname")
484+ }
485+ return nil
486+}
487+
488+// verifyKeyPair checks that a certificate's public key matches the private key.
489+func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error {
490+ certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
491+ if !ok {
492+ return fmt.Errorf("certificate has non-ECDSA public key")
493+ }
494+ keyPub, ok := key.Public().(*ecdsa.PublicKey)
495+ if !ok {
496+ return fmt.Errorf("signer has non-ECDSA public key")
497+ }
498+ if !certPub.Equal(keyPub) {
499+ return fmt.Errorf("public keys do not match")
500+ }
501+ return nil
398502 }
@@ -11,8 +11,10 @@ import (
11 "crypto/x509"11 "crypto/x509"
12 "crypto/x509/pkix"12 "crypto/x509/pkix"
13 "encoding/pem"13 "encoding/pem"
14+ "errors"
14 "fmt"15 "fmt"
15 "math/big"16 "math/big"
17+ "net"
16 "os"18 "os"
17 "path/filepath"19 "path/filepath"
18 "strings"20 "strings"
@@ -48,6 +50,8 @@ type CA struct {
48 mu sync.RWMutex50 mu sync.RWMutex
49 interCert *x509.Certificate51 interCert *x509.Certificate
50 interKey crypto.Signer52 interKey crypto.Signer
53+
54+ renewMu sync.Mutex // serialises intermediate renewal
51 }55 }
52 56
53 // SetAIAURL sets the Authority Information Access URL embedded in leaf certs.57 // SetAIAURL sets the Authority Information Access URL embedded in leaf certs.
@@ -109,6 +113,9 @@ func (ca *CA) IntermediateExpiresAt() time.Time {
109 // RenewIntermediate regenerates the intermediate CA certificate,113 // RenewIntermediate regenerates the intermediate CA certificate,
110 // signed by the root CA. Existing leaf certs remain valid.114 // signed by the root CA. Existing leaf certs remain valid.
111 func (ca *CA) RenewIntermediate() error {115 func (ca *CA) RenewIntermediate() error {
116+ ca.renewMu.Lock()
117+ defer ca.renewMu.Unlock()
118+
112 cert, key, err := ca.generateIntermediate()119 cert, key, err := ca.generateIntermediate()
113 if err != nil {120 if err != nil {
114 return err121 return err
@@ -131,18 +138,39 @@ func (ca *CA) loadOrGenerateRoot() error {
131 certPEM, certErr := os.ReadFile(certPath)138 certPEM, certErr := os.ReadFile(certPath)
132 keyPEM, keyErr := os.ReadFile(keyPath)139 keyPEM, keyErr := os.ReadFile(keyPath)
133 140
134- if certErr == nil && keyErr == nil {141+ certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
142+ keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
143+
144+ switch {
145+ case certErr == nil && keyErr == nil:
135 cert, key, err := parseCertAndKey(certPEM, keyPEM)146 cert, key, err := parseCertAndKey(certPEM, keyPEM)
136- if err == nil {147+ if err != nil {
137- ca.rootCert = cert148+ return fmt.Errorf("corrupt root CA files: %w", err)
138- ca.rootKey = key
139- ca.rootPEM = certPEM
140- ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
141- return nil
142 }149 }
143- ca.log.Warn().Err(err).Msg("failed to parse existing root CA, regenerating")150+ if err := verifyKeyPair(cert, key); err != nil {
151+ return fmt.Errorf("root CA cert/key mismatch: %w", err)
152+ }
153+ ca.rootCert = cert
154+ ca.rootKey = key
155+ ca.rootPEM = certPEM
156+ ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA")
157+ return nil
158+
159+ case certMissing && keyMissing:
160+ return ca.bootstrapRoot(certPath, keyPath)
161+
162+ case certMissing || keyMissing:
163+ return fmt.Errorf("incomplete root CA: cert missing=%v, key missing=%v", certMissing, keyMissing)
164+
165+ default:
166+ if certErr != nil {
167+ return fmt.Errorf("read root cert: %w", certErr)
168+ }
169+ return fmt.Errorf("read root key: %w", keyErr)
144 }170 }
171+}
145 172
173+func (ca *CA) bootstrapRoot(certPath, keyPath string) error {
146 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)174 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
147 if err != nil {175 if err != nil {
148 return fmt.Errorf("generate root key: %w", err)176 return fmt.Errorf("generate root key: %w", err)
@@ -178,12 +206,12 @@ func (ca *CA) loadOrGenerateRoot() error {
178 return fmt.Errorf("parse root cert: %w", err)206 return fmt.Errorf("parse root cert: %w", err)
179 }207 }
180 208
181- certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})209+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
182 keyDER, err := x509.MarshalECPrivateKey(key)210 keyDER, err := x509.MarshalECPrivateKey(key)
183 if err != nil {211 if err != nil {
184 return fmt.Errorf("marshal root key: %w", err)212 return fmt.Errorf("marshal root key: %w", err)
185 }213 }
186- keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})214+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
187 215
188 if err := writeSecure(certPath, certPEM, 0644); err != nil {216 if err := writeSecure(certPath, certPEM, 0644); err != nil {
189 return err217 return err
@@ -208,7 +236,13 @@ func (ca *CA) loadOrGenerateIntermediate() error {
208 236
209 if certErr == nil && keyErr == nil {237 if certErr == nil && keyErr == nil {
210 cert, key, err := parseCertAndKey(certPEM, keyPEM)238 cert, key, err := parseCertAndKey(certPEM, keyPEM)
211- if err == nil && time.Now().Before(cert.NotAfter) {239+ if err != nil {
240+ ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
241+ } else if err := verifyKeyPair(cert, key); err != nil {
242+ ca.log.Warn().Err(err).Msg("intermediate CA cert/key mismatch, regenerating")
243+ } else if err := cert.CheckSignatureFrom(ca.rootCert); err != nil {
244+ ca.log.Warn().Err(err).Msg("intermediate CA not signed by current root, regenerating")
245+ } else if time.Now().Before(cert.NotAfter) {
212 ca.interCert = cert246 ca.interCert = cert
213 ca.interKey = key247 ca.interKey = key
214 ca.log.Info().248 ca.log.Info().
@@ -216,9 +250,6 @@ func (ca *CA) loadOrGenerateIntermediate() error {
216 Time("expires", cert.NotAfter).250 Time("expires", cert.NotAfter).
217 Msg("loaded existing intermediate CA")251 Msg("loaded existing intermediate CA")
218 return nil252 return nil
219- }
220- if err != nil {
221- ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
222 } else {253 } else {
223 ca.log.Warn().Msg("intermediate CA expired, regenerating")254 ca.log.Warn().Msg("intermediate CA expired, regenerating")
224 }255 }
@@ -300,6 +331,10 @@ func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) e
300 // IssueCertificate generates a leaf cert for the given domain,331 // IssueCertificate generates a leaf cert for the given domain,
301 // signed by the current intermediate CA.332 // signed by the current intermediate CA.
302 func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {333 func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
334+ if err := validateDomain(domain); err != nil {
335+ return nil, fmt.Errorf("invalid domain: %w", err)
336+ }
337+
303 ca.mu.RLock()338 ca.mu.RLock()
304 interCert := ca.interCert339 interCert := ca.interCert
305 interKey := ca.interKey340 interKey := ca.interKey
@@ -348,6 +383,8 @@ func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
348 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})383 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
349 384
350 tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)385 tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM)
386+ clear(keyDER)
387+ clear(keyPEM)
351 if err != nil {388 if err != nil {
352 return nil, fmt.Errorf("create TLS cert: %w", err)389 return nil, fmt.Errorf("create TLS cert: %w", err)
353 }390 }
@@ -360,6 +397,11 @@ func (ca *CA) LeafLifetime() time.Duration {
360 return leafLifetime397 return leafLifetime
361 }398 }
362 399
400+// IntermediateLifetime returns the configured intermediate CA lifetime.
401+func (ca *CA) IntermediateLifetime() time.Duration {
402+ return intermediateLifetime
403+}
404+
363 func randomSerial() (*big.Int, error) {405 func randomSerial() (*big.Int, error) {
364 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))406 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
365 if err != nil {407 if err != nil {
@@ -390,9 +432,71 @@ func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer,
390 return cert, key, nil432 return cert, key, nil
391 }433 }
392 434
393-func writeSecure(path string, data []byte, perm os.FileMode) error {435+func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) {
394- if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {436+ dir := filepath.Dir(path)
437+ if err := os.MkdirAll(dir, 0700); err != nil {
395 return fmt.Errorf("create dir for %s: %w", path, err)438 return fmt.Errorf("create dir for %s: %w", path, err)
396 }439 }
397- return os.WriteFile(path, data, perm)440+ // Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions.
441+ tmp, err := os.CreateTemp(dir, ".tmp-*")
442+ if err != nil {
443+ return fmt.Errorf("create temp file for %s: %w", path, err)
444+ }
445+ tmpName := tmp.Name()
446+ defer func() {
447+ if retErr != nil {
448+ os.Remove(tmpName)
449+ }
450+ }()
451+ if _, err := tmp.Write(data); err != nil {
452+ tmp.Close()
453+ return fmt.Errorf("write temp file for %s: %w", path, err)
454+ }
455+ if err := tmp.Close(); err != nil {
456+ return fmt.Errorf("close temp file for %s: %w", path, err)
457+ }
458+ if err := os.Chmod(tmpName, perm); err != nil {
459+ return fmt.Errorf("chmod temp file for %s: %w", path, err)
460+ }
461+ if err := os.Rename(tmpName, path); err != nil {
462+ return fmt.Errorf("rename temp file to %s: %w", path, err)
463+ }
464+ return nil
465+}
466+
467+// validateDomain performs defense-in-depth validation of a domain name
468+// before issuing a certificate.
469+func validateDomain(domain string) error {
470+ if domain == "" {
471+ return fmt.Errorf("empty domain")
472+ }
473+ if len(domain) > 253 {
474+ return fmt.Errorf("domain too long (%d chars)", len(domain))
475+ }
476+ if strings.HasPrefix(domain, "*") {
477+ return fmt.Errorf("wildcard domains not allowed")
478+ }
479+ if strings.ContainsAny(domain, "/\\") {
480+ return fmt.Errorf("domain contains path separator")
481+ }
482+ if net.ParseIP(domain) != nil {
483+ return fmt.Errorf("IP addresses not allowed, must be a hostname")
484+ }
485+ return nil
486+}
487+
488+// verifyKeyPair checks that a certificate's public key matches the private key.
489+func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error {
490+ certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
491+ if !ok {
492+ return fmt.Errorf("certificate has non-ECDSA public key")
493+ }
494+ keyPub, ok := key.Public().(*ecdsa.PublicKey)
495+ if !ok {
496+ return fmt.Errorf("signer has non-ECDSA public key")
497+ }
498+ if !certPub.Equal(keyPub) {
499+ return fmt.Errorf("public keys do not match")
500+ }
501+ return nil
398 }502 }
M internal/adapters/out/pki/mobileconfig.go +3 -1
@@ -3,6 +3,7 @@ package pki
33 import (
44 "encoding/base64"
55 "fmt"
6+ "html"
67
78 "github.com/google/uuid"
89 )
@@ -14,6 +15,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
1415 profileUUID := uuid.New().String()
1516 certUUID := uuid.New().String()
1617
18+ safeCN := html.EscapeString(rootCN)
1719 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
1820 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1921 <plist version="1.0">
@@ -52,7 +54,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
5254 <key>PayloadVersion</key>
5355 <integer>1</integer>
5456 </dict>
55-</plist>`, b64Cert, rootCN, certUUID, certUUID, profileUUID)
57+</plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID)
5658
5759 return []byte(profile)
5860 }
@@ -3,6 +3,7 @@ package pki
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5 "fmt"5 "fmt"
6+ "html"
6 7
7 "github.com/google/uuid"8 "github.com/google/uuid"
8 )9 )
@@ -14,6 +15,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
14 profileUUID := uuid.New().String()15 profileUUID := uuid.New().String()
15 certUUID := uuid.New().String()16 certUUID := uuid.New().String()
16 17
18+ safeCN := html.EscapeString(rootCN)
17 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>19 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
18 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">20 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19 <plist version="1.0">21 <plist version="1.0">
@@ -52,7 +54,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
52 <key>PayloadVersion</key>54 <key>PayloadVersion</key>
53 <integer>1</integer>55 <integer>1</integer>
54 </dict>56 </dict>
55-</plist>`, b64Cert, rootCN, certUUID, certUUID, profileUUID)57+</plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID)
56 58
57 return []byte(profile)59 return []byte(profile)
58 }60 }
M internal/adapters/out/pki/trust.go +11 -4
@@ -2,22 +2,29 @@ package pki
22
33 import (
44 "crypto/x509"
5+ "fmt"
56
67 "github.com/smallstep/truststore"
78 )
89
910 // InstallRoot installs the root CA certificate into the system trust store.
1011 func InstallRoot(cert *x509.Certificate) error {
11- return truststore.Install(cert,
12+ if err := truststore.Install(cert,
1213 truststore.WithFirefox(),
1314 truststore.WithJava(),
14- )
15+ ); err != nil {
16+ return fmt.Errorf("install root certificate: %w", err)
17+ }
18+ return nil
1519 }
1620
1721 // UninstallRoot removes the root CA certificate from the system trust store.
1822 func UninstallRoot(cert *x509.Certificate) error {
19- return truststore.Uninstall(cert,
23+ if err := truststore.Uninstall(cert,
2024 truststore.WithFirefox(),
2125 truststore.WithJava(),
22- )
26+ ); err != nil {
27+ return fmt.Errorf("uninstall root certificate: %w", err)
28+ }
29+ return nil
2330 }
@@ -2,22 +2,29 @@ package pki
2 2
3 import (3 import (
4 "crypto/x509"4 "crypto/x509"
5+ "fmt"
5 6
6 "github.com/smallstep/truststore"7 "github.com/smallstep/truststore"
7 )8 )
8 9
9 // InstallRoot installs the root CA certificate into the system trust store.10 // InstallRoot installs the root CA certificate into the system trust store.
10 func InstallRoot(cert *x509.Certificate) error {11 func InstallRoot(cert *x509.Certificate) error {
11- return truststore.Install(cert,12+ if err := truststore.Install(cert,
12 truststore.WithFirefox(),13 truststore.WithFirefox(),
13 truststore.WithJava(),14 truststore.WithJava(),
14- )15+ ); err != nil {
16+ return fmt.Errorf("install root certificate: %w", err)
17+ }
18+ return nil
15 }19 }
16 20
17 // UninstallRoot removes the root CA certificate from the system trust store.21 // UninstallRoot removes the root CA certificate from the system trust store.
18 func UninstallRoot(cert *x509.Certificate) error {22 func UninstallRoot(cert *x509.Certificate) error {
19- return truststore.Uninstall(cert,23+ if err := truststore.Uninstall(cert,
20 truststore.WithFirefox(),24 truststore.WithFirefox(),
21 truststore.WithJava(),25 truststore.WithJava(),
22- )26+ ); err != nil {
27+ return fmt.Errorf("uninstall root certificate: %w", err)
28+ }
29+ return nil
23 }30 }
M internal/app/pki_integration_test.go +4 -0
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
12+ outboundary "github.com/bnema/gordon/internal/boundaries/out"
1213 "github.com/bnema/gordon/internal/domain"
1314 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
1415 "github.com/bnema/zerowrap"
@@ -16,6 +17,9 @@ import (
1617 "github.com/stretchr/testify/require"
1718 )
1819
20+// Compile-time check: stubRoutes implements the boundary interface.
21+var _ outboundary.RouteChecker = (*stubRoutes)(nil)
22+
1923 type stubRoutes struct {
2024 routes []domain.Route
2125 }
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"11 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
12+ outboundary "github.com/bnema/gordon/internal/boundaries/out"
12 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
13 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"14 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -16,6 +17,9 @@ import (
16 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
17 )18 )
18 19
20+// Compile-time check: stubRoutes implements the boundary interface.
21+var _ outboundary.RouteChecker = (*stubRoutes)(nil)
22+
19 type stubRoutes struct {23 type stubRoutes struct {
20 routes []domain.Route24 routes []domain.Route
21 }25 }
M internal/app/run.go +23 -21
@@ -202,7 +202,6 @@ type services struct {
202202 maxBlobChunkSize int64
203203 caAdapter *pkiadapter.CA
204204 pkiSvc *pkiusecase.Service
205- proxyAllowedNets []*net.IPNet // Cloudflare CIDRs for onboarding handler
206205 }
207206
208207 // Run initializes and starts the Gordon application.
@@ -1581,14 +1580,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
15811580 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
15821581
15831582 // HTTP proxy handler chain: CIDR + onboarding routes + proxy
1584- var proxyAllowedNets []*net.IPNet
1585- var proxyCIDRMiddleware func(http.Handler) http.Handler
1586- proxyAllowedNets, proxyCIDRMiddleware = buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
1587- svc.proxyAllowedNets = proxyAllowedNets
1583+ _, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
15881584
15891585 httpProxyMiddlewares := []func(http.Handler) http.Handler{
15901586 middleware.PanicRecovery(log),
15911587 middleware.RequestLogger(log, trustedNets),
1588+ middleware.SecurityHeaders,
15921589 }
15931590 if proxyCIDRMiddleware != nil {
15941591 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -1599,31 +1596,36 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
15991596 "gordon.proxy",
16001597 )
16011598
1602- // Build proxyMux with onboarding routes before catch-all
1599+ // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding
16031600 proxyMux := http.NewServeMux()
1601+ proxyMux.Handle("/", httpProxyWithMiddleware)
1602+
1603+ // HTTPS proxy handler chain: security headers + proxy + CA onboarding
1604+ // Onboarding routes live on the TLS port so Tailnet / direct clients
1605+ // can click through the initial cert warning, install the CA, and
1606+ // then trust all subsequent connections.
1607+ httpsProxyMiddlewares := []func(http.Handler) http.Handler{
1608+ middleware.PanicRecovery(log),
1609+ middleware.RequestLogger(log, trustedNets),
1610+ middleware.SecurityHeaders,
1611+ }
1612+ httpsProxyWithMiddleware := middleware.Chain(httpsProxyMiddlewares...)(proxyHandler)
1613+
1614+ httpsMux := http.NewServeMux()
16041615 if svc.caAdapter != nil {
16051616 onboardingHandler := onboarding.NewHandler(
16061617 svc.caAdapter.RootCertificate(),
16071618 svc.caAdapter.RootCertificateDER(),
16081619 svc.caAdapter.RootCommonName(),
1609- svc.proxyAllowedNets,
1620+ cfg.Server.TLSPort,
16101621 )
1611- proxyMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)
1612- proxyMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
1613- proxyMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
1622+ httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)
1623+ httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
1624+ httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
16141625 }
1615- proxyMux.Handle("/", httpProxyWithMiddleware)
1626+ httpsMux.Handle("/", httpsProxyWithMiddleware)
16161627
1617- // HTTPS proxy handler chain: security headers + proxy (no CIDR, no onboarding)
1618- httpsProxyMiddlewares := []func(http.Handler) http.Handler{
1619- middleware.PanicRecovery(log),
1620- middleware.RequestLogger(log, trustedNets),
1621- middleware.SecurityHeaders,
1622- }
1623- httpsHandler := otelhttp.NewHandler(
1624- middleware.Chain(httpsProxyMiddlewares...)(proxyHandler),
1625- "gordon.proxy.tls",
1626- )
1628+ httpsHandler := otelhttp.NewHandler(httpsMux, "gordon.proxy.tls")
16271629
16281630 return registryMux, proxyMux, httpsHandler
16291631 }
@@ -202,7 +202,6 @@ type services struct {
202 maxBlobChunkSize int64202 maxBlobChunkSize int64
203 caAdapter *pkiadapter.CA203 caAdapter *pkiadapter.CA
204 pkiSvc *pkiusecase.Service204 pkiSvc *pkiusecase.Service
205- proxyAllowedNets []*net.IPNet // Cloudflare CIDRs for onboarding handler
206 }205 }
207 206
208 // Run initializes and starts the Gordon application.207 // Run initializes and starts the Gordon application.
@@ -1581,14 +1580,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1581 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)1580 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
1582 1581
1583 // HTTP proxy handler chain: CIDR + onboarding routes + proxy1582 // HTTP proxy handler chain: CIDR + onboarding routes + proxy
1584- var proxyAllowedNets []*net.IPNet1583+ _, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
1585- var proxyCIDRMiddleware func(http.Handler) http.Handler
1586- proxyAllowedNets, proxyCIDRMiddleware = buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
1587- svc.proxyAllowedNets = proxyAllowedNets
1588 1584
1589 httpProxyMiddlewares := []func(http.Handler) http.Handler{1585 httpProxyMiddlewares := []func(http.Handler) http.Handler{
1590 middleware.PanicRecovery(log),1586 middleware.PanicRecovery(log),
1591 middleware.RequestLogger(log, trustedNets),1587 middleware.RequestLogger(log, trustedNets),
1588+ middleware.SecurityHeaders,
1592 }1589 }
1593 if proxyCIDRMiddleware != nil {1590 if proxyCIDRMiddleware != nil {
1594 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)1591 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -1599,31 +1596,36 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1599 "gordon.proxy",1596 "gordon.proxy",
1600 )1597 )
1601 1598
1602- // Build proxyMux with onboarding routes before catch-all1599+ // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding
1603 proxyMux := http.NewServeMux()1600 proxyMux := http.NewServeMux()
1601+ proxyMux.Handle("/", httpProxyWithMiddleware)
1602+
1603+ // HTTPS proxy handler chain: security headers + proxy + CA onboarding
1604+ // Onboarding routes live on the TLS port so Tailnet / direct clients
1605+ // can click through the initial cert warning, install the CA, and
1606+ // then trust all subsequent connections.
1607+ httpsProxyMiddlewares := []func(http.Handler) http.Handler{
1608+ middleware.PanicRecovery(log),
1609+ middleware.RequestLogger(log, trustedNets),
1610+ middleware.SecurityHeaders,
1611+ }
1612+ httpsProxyWithMiddleware := middleware.Chain(httpsProxyMiddlewares...)(proxyHandler)
1613+
1614+ httpsMux := http.NewServeMux()
1604 if svc.caAdapter != nil {1615 if svc.caAdapter != nil {
1605 onboardingHandler := onboarding.NewHandler(1616 onboardingHandler := onboarding.NewHandler(
1606 svc.caAdapter.RootCertificate(),1617 svc.caAdapter.RootCertificate(),
1607 svc.caAdapter.RootCertificateDER(),1618 svc.caAdapter.RootCertificateDER(),
1608 svc.caAdapter.RootCommonName(),1619 svc.caAdapter.RootCommonName(),
1609- svc.proxyAllowedNets,1620+ cfg.Server.TLSPort,
1610 )1621 )
1611- proxyMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)1622+ httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)
1612- proxyMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)1623+ httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
1613- proxyMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)1624+ httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
1614 }1625 }
1615- proxyMux.Handle("/", httpProxyWithMiddleware)1626+ httpsMux.Handle("/", httpsProxyWithMiddleware)
1616 1627
1617- // HTTPS proxy handler chain: security headers + proxy (no CIDR, no onboarding)1628+ httpsHandler := otelhttp.NewHandler(httpsMux, "gordon.proxy.tls")
1618- httpsProxyMiddlewares := []func(http.Handler) http.Handler{
1619- middleware.PanicRecovery(log),
1620- middleware.RequestLogger(log, trustedNets),
1621- middleware.SecurityHeaders,
1622- }
1623- httpsHandler := otelhttp.NewHandler(
1624- middleware.Chain(httpsProxyMiddlewares...)(proxyHandler),
1625- "gordon.proxy.tls",
1626- )
1627 1629
1628 return registryMux, proxyMux, httpsHandler1630 return registryMux, proxyMux, httpsHandler
1629 }1631 }
M internal/boundaries/out/pki.go +15 -0
@@ -1,10 +1,19 @@
11 package out
22
33 import (
4+ "context"
45 "crypto/tls"
56 "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
69 )
710
11+// RouteChecker provides route lookup for domain validation.
12+type RouteChecker interface {
13+ GetRoutes(ctx context.Context) []domain.Route
14+ GetExternalRoutes() map[string]string
15+}
16+
817 // CertificateAuthority provides internal PKI operations.
918 // The adapter handles only cryptography — allowlist checks and caching
1019 // belong in the use case layer.
@@ -34,4 +43,10 @@ type CertificateAuthority interface {
3443
3544 // RootCommonName returns the CN of the root CA certificate.
3645 RootCommonName() string
46+
47+ // LeafLifetime returns the configured leaf certificate lifetime.
48+ LeafLifetime() time.Duration
49+
50+ // IntermediateLifetime returns the configured intermediate CA lifetime.
51+ IntermediateLifetime() time.Duration
3752 }
@@ -1,10 +1,19 @@
1 package out1 package out
2 2
3 import (3 import (
4+ "context"
4 "crypto/tls"5 "crypto/tls"
5 "time"6 "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
6 )9 )
7 10
11+// RouteChecker provides route lookup for domain validation.
12+type RouteChecker interface {
13+ GetRoutes(ctx context.Context) []domain.Route
14+ GetExternalRoutes() map[string]string
15+}
16+
8 // CertificateAuthority provides internal PKI operations.17 // CertificateAuthority provides internal PKI operations.
9 // The adapter handles only cryptography — allowlist checks and caching18 // The adapter handles only cryptography — allowlist checks and caching
10 // belong in the use case layer.19 // belong in the use case layer.
@@ -34,4 +43,10 @@ type CertificateAuthority interface {
34 43
35 // RootCommonName returns the CN of the root CA certificate.44 // RootCommonName returns the CN of the root CA certificate.
36 RootCommonName() string45 RootCommonName() string
46+
47+ // LeafLifetime returns the configured leaf certificate lifetime.
48+ LeafLifetime() time.Duration
49+
50+ // IntermediateLifetime returns the configured intermediate CA lifetime.
51+ IntermediateLifetime() time.Duration
37 }52 }
M internal/usecase/pki/service.go +15 -23
@@ -10,7 +10,6 @@ import (
1010 "github.com/bnema/zerowrap"
1111
1212 "github.com/bnema/gordon/internal/boundaries/out"
13- "github.com/bnema/gordon/internal/domain"
1413 )
1514
1615 const (
@@ -23,18 +22,14 @@ type cachedCert struct {
2322 expiresAt time.Time
2423 }
2524
26-// RouteChecker provides route lookup for domain validation.
27-// Matches the subset of in.ConfigService needed here.
28-type RouteChecker interface {
29- GetRoutes(ctx context.Context) []domain.Route
30- GetExternalRoutes() map[string]string
31-}
32-
3325 // Service provides on-demand TLS certificate issuance with caching
3426 // and automatic intermediate CA renewal.
27+//
28+// TODO: consider adding CRL/OCSP support for certificate revocation,
29+// especially given the 10-year root CA lifetime.
3530 type Service struct {
3631 ca out.CertificateAuthority
37- routes RouteChecker
32+ routes out.RouteChecker
3833 log zerowrap.Logger
3934
4035 cache sync.Map // map[string]*cachedCert
@@ -43,7 +38,7 @@ type Service struct {
4338 }
4439
4540 // NewService creates a PKI service and starts background maintenance goroutines.
46-func NewService(ctx context.Context, ca out.CertificateAuthority, routes RouteChecker, log zerowrap.Logger) *Service {
41+func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
4742 ctx, cancel := context.WithCancel(ctx)
4843 svc := &Service{
4944 ca: ca,
@@ -80,9 +75,10 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
8075 }
8176
8277 if entry, ok := s.cache.Load(domain); ok {
83- cached := entry.(*cachedCert)
84- if time.Now().Before(cached.expiresAt) {
85- return cached.cert, nil
78+ if cached, ok := entry.(*cachedCert); ok {
79+ if time.Now().Before(cached.expiresAt) {
80+ return cached.cert, nil
81+ }
8682 }
8783 s.cache.Delete(domain)
8884 }
@@ -119,12 +115,7 @@ func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
119115 }
120116
121117 func (s *Service) leafLifetime() time.Duration {
122- // Use the CA's leaf lifetime if it exposes it, otherwise default 12h.
123- type lifetimer interface{ LeafLifetime() time.Duration }
124- if lt, ok := s.ca.(lifetimer); ok {
125- return lt.LeafLifetime()
126- }
127- return 12 * time.Hour
118+ return s.ca.LeafLifetime()
128119 }
129120
130121 func (s *Service) maintenanceLoop(ctx context.Context) {
@@ -146,8 +137,7 @@ func (s *Service) maintenanceLoop(ctx context.Context) {
146137 func (s *Service) renewIntermediateIfNeeded() {
147138 expiresAt := s.ca.IntermediateExpiresAt()
148139 remaining := time.Until(expiresAt)
149- // Total lifetime ~ 7 days. Renewal window = 20% ~ 1.4 days.
150- lifetime := 7 * 24 * time.Hour
140+ lifetime := s.ca.IntermediateLifetime()
151141 window := time.Duration(float64(lifetime) * renewalWindowRatio)
152142 if remaining < window {
153143 s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
@@ -159,16 +149,18 @@ func (s *Service) renewIntermediateIfNeeded() {
159149
160150 func (s *Service) sweepExpiredCerts() {
161151 now := time.Now()
152+ ctx := context.Background()
162153 swept := 0
163154 s.cache.Range(func(key, value any) bool {
155+ domain := key.(string)
164156 cached := value.(*cachedCert)
165- if now.After(cached.expiresAt) {
157+ if now.After(cached.expiresAt) || !s.isDomainAllowed(ctx, domain) {
166158 s.cache.Delete(key)
167159 swept++
168160 }
169161 return true
170162 })
171163 if swept > 0 {
172- s.log.Debug().Int("swept", swept).Msg("cleaned expired leaf certs from cache")
164+ s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache")
173165 }
174166 }
@@ -10,7 +10,6 @@ import (
10 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
11 11
12 "github.com/bnema/gordon/internal/boundaries/out"12 "github.com/bnema/gordon/internal/boundaries/out"
13- "github.com/bnema/gordon/internal/domain"
14 )13 )
15 14
16 const (15 const (
@@ -23,18 +22,14 @@ type cachedCert struct {
23 expiresAt time.Time22 expiresAt time.Time
24 }23 }
25 24
26-// RouteChecker provides route lookup for domain validation.
27-// Matches the subset of in.ConfigService needed here.
28-type RouteChecker interface {
29- GetRoutes(ctx context.Context) []domain.Route
30- GetExternalRoutes() map[string]string
31-}
32-
33 // Service provides on-demand TLS certificate issuance with caching25 // Service provides on-demand TLS certificate issuance with caching
34 // and automatic intermediate CA renewal.26 // and automatic intermediate CA renewal.
27+//
28+// TODO: consider adding CRL/OCSP support for certificate revocation,
29+// especially given the 10-year root CA lifetime.
35 type Service struct {30 type Service struct {
36 ca out.CertificateAuthority31 ca out.CertificateAuthority
37- routes RouteChecker32+ routes out.RouteChecker
38 log zerowrap.Logger33 log zerowrap.Logger
39 34
40 cache sync.Map // map[string]*cachedCert35 cache sync.Map // map[string]*cachedCert
@@ -43,7 +38,7 @@ type Service struct {
43 }38 }
44 39
45 // NewService creates a PKI service and starts background maintenance goroutines.40 // NewService creates a PKI service and starts background maintenance goroutines.
46-func NewService(ctx context.Context, ca out.CertificateAuthority, routes RouteChecker, log zerowrap.Logger) *Service {41+func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
47 ctx, cancel := context.WithCancel(ctx)42 ctx, cancel := context.WithCancel(ctx)
48 svc := &Service{43 svc := &Service{
49 ca: ca,44 ca: ca,
@@ -80,9 +75,10 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
80 }75 }
81 76
82 if entry, ok := s.cache.Load(domain); ok {77 if entry, ok := s.cache.Load(domain); ok {
83- cached := entry.(*cachedCert)78+ if cached, ok := entry.(*cachedCert); ok {
84- if time.Now().Before(cached.expiresAt) {79+ if time.Now().Before(cached.expiresAt) {
85- return cached.cert, nil80+ return cached.cert, nil
81+ }
86 }82 }
87 s.cache.Delete(domain)83 s.cache.Delete(domain)
88 }84 }
@@ -119,12 +115,7 @@ func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
119 }115 }
120 116
121 func (s *Service) leafLifetime() time.Duration {117 func (s *Service) leafLifetime() time.Duration {
122- // Use the CA's leaf lifetime if it exposes it, otherwise default 12h.118+ return s.ca.LeafLifetime()
123- type lifetimer interface{ LeafLifetime() time.Duration }
124- if lt, ok := s.ca.(lifetimer); ok {
125- return lt.LeafLifetime()
126- }
127- return 12 * time.Hour
128 }119 }
129 120
130 func (s *Service) maintenanceLoop(ctx context.Context) {121 func (s *Service) maintenanceLoop(ctx context.Context) {
@@ -146,8 +137,7 @@ func (s *Service) maintenanceLoop(ctx context.Context) {
146 func (s *Service) renewIntermediateIfNeeded() {137 func (s *Service) renewIntermediateIfNeeded() {
147 expiresAt := s.ca.IntermediateExpiresAt()138 expiresAt := s.ca.IntermediateExpiresAt()
148 remaining := time.Until(expiresAt)139 remaining := time.Until(expiresAt)
149- // Total lifetime ~ 7 days. Renewal window = 20% ~ 1.4 days.140+ lifetime := s.ca.IntermediateLifetime()
150- lifetime := 7 * 24 * time.Hour
151 window := time.Duration(float64(lifetime) * renewalWindowRatio)141 window := time.Duration(float64(lifetime) * renewalWindowRatio)
152 if remaining < window {142 if remaining < window {
153 s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")143 s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
@@ -159,16 +149,18 @@ func (s *Service) renewIntermediateIfNeeded() {
159 149
160 func (s *Service) sweepExpiredCerts() {150 func (s *Service) sweepExpiredCerts() {
161 now := time.Now()151 now := time.Now()
152+ ctx := context.Background()
162 swept := 0153 swept := 0
163 s.cache.Range(func(key, value any) bool {154 s.cache.Range(func(key, value any) bool {
155+ domain := key.(string)
164 cached := value.(*cachedCert)156 cached := value.(*cachedCert)
165- if now.After(cached.expiresAt) {157+ if now.After(cached.expiresAt) || !s.isDomainAllowed(ctx, domain) {
166 s.cache.Delete(key)158 s.cache.Delete(key)
167 swept++159 swept++
168 }160 }
169 return true161 return true
170 })162 })
171 if swept > 0 {163 if swept > 0 {
172- s.log.Debug().Int("swept", swept).Msg("cleaned expired leaf certs from cache")164+ s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache")
173 }165 }
174 }166 }
M internal/usecase/pki/service_test.go +5 -1
@@ -10,11 +10,15 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
13+ outboundary "github.com/bnema/gordon/internal/boundaries/out"
1314 "github.com/bnema/gordon/internal/domain"
1415 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
1516 )
1617
17-// stubConfigService satisfies pkiusecase.RouteChecker
18+// Compile-time check: stubConfigService implements the boundary interface.
19+var _ outboundary.RouteChecker = (*stubConfigService)(nil)
20+
21+// stubConfigService satisfies out.RouteChecker
1822 type stubConfigService struct {
1923 routes []domain.Route
2024 externalRoutes map[string]string
@@ -10,11 +10,15 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"12 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
13+ outboundary "github.com/bnema/gordon/internal/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
14 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"15 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
15 )16 )
16 17
17-// stubConfigService satisfies pkiusecase.RouteChecker18+// Compile-time check: stubConfigService implements the boundary interface.
19+var _ outboundary.RouteChecker = (*stubConfigService)(nil)
20+
21+// stubConfigService satisfies out.RouteChecker
18 type stubConfigService struct {22 type stubConfigService struct {
19 routes []domain.Route23 routes []domain.Route
20 externalRoutes map[string]string24 externalRoutes map[string]string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close