From: Dan Fuhry Date: Tue, 13 Oct 2026 13:19:28 +0000 (-0400) Subject: [http] mtls, authzn overhaul X-Git-Url: https://go.fuhry.dev/?a=commitdiff_plain;h=5f299313d15e89704a0e261c6c19c6b53f4ebf60;p=runtime.git [http] mtls, authzn overhaul - authorizer route actions are replaced with a separate interface - add support for mTLS server identities and client cert validation - add client cert authorizer with fine grained control over SPIFFE domains, users & services - fully support hot reload for TLS using hotSwappableTLSConfig - add HTTPError struct type (implements error) to wrap http errors - support tls config at both listener- and vhost-level supporting changes: - mtls: open up interface to allow checking subjects separately from cert validity - mtls: add `VerifyYamlConfig` type for reusable partials - mtls: add certObject primitive (public interface: `SwappableCert`) for concurrency-safe, guaranteed consistent access to cert object types that can briefly be inconsistent in underlying storage (e.g. filesystem) - mtls/certutil: add SplitChain, SplitChainRoot, and IsSelfSigned functions - mtls: cleanup deprecated IdentityDriver types - mtls: add `CertificateNotifyOnReload` interface for cert types that can perform watch-/monitor-based reloads known limitations: - MTLSPeerVerifier really does not incompatible config change: `auth` route property changes from string to []string **🚫🤖 No AI** --- diff --git a/http/BUILD.bazel b/http/BUILD.bazel index 91f2ea7..31a3619 100644 --- a/http/BUILD.bazel +++ b/http/BUILD.bazel @@ -4,6 +4,8 @@ load("//bazel:go.bzl", "go_multi_library") go_multi_library( name = "http", srcs = [ + "authorizer_client_cert.go", + "authorizer_saml.go", "http.go", "proxy_protocol.go", "range.go", @@ -11,7 +13,6 @@ go_multi_library( "route_action_proxy.go", "route_action_redirect.go", "route_action_s3.go", - "route_action_saml.go", "server.go", "sni_listener.go", "tcp.go", diff --git a/http/authorizer_client_cert.go b/http/authorizer_client_cert.go new file mode 100644 index 0000000..7d8ce21 --- /dev/null +++ b/http/authorizer_client_cert.go @@ -0,0 +1,108 @@ +package http + +import ( + "crypto/tls" + "errors" + "fmt" + "net/http" + + "gopkg.in/yaml.v3" + + "go.fuhry.dev/runtime/mtls" + "go.fuhry.dev/runtime/utils/log" +) + +type clientCertAuthorizer struct{} + +var _ Authorizer = &clientCertAuthorizer{} + +type clientCertAuthorizationHandler struct { + verbose bool + verifier mtls.MTLSPeerVerifier +} + +var _ AuthorizationHandler = &clientCertAuthorizationHandler{} + +const ( + clientCertAuthorizerName = "client_certificate" +) + +func (a *clientCertAuthorizer) New(node *yaml.Node) (AuthorizationHandler, error) { + var rawNode struct { + Verbose bool `yaml:"verbose_errors"` + } + + if err := node.Decode(&rawNode); err != nil { + return nil, err + } + + rules, err := mtls.NewVerifyYamlConfig(node) + if err != nil { + return nil, err + } + + handler := &clientCertAuthorizationHandler{ + verifier: mtls.NewPeerNameVerifier(), + verbose: rawNode.Verbose, + } + if err := rules.Apply(handler.verifier); err != nil { + return nil, err + } + + return handler, nil +} + +func (h *clientCertAuthorizationHandler) Check(_ http.ResponseWriter, r *http.Request) (string, error) { + logger := LoggerFromContext(r.Context()) + + vh := VirtualHostFromContext(r.Context()) + if vh == nil { + return "", errors.New("could not look up virtual host from request context") + } + + var rid *mtls.RemoteIdentity + if tlsConn, ok := r.Context().Value(kTlsConn).(*tls.Conn); ok { + if verifier := vh.(*HTTPVirtualHost).tlsVerifier; verifier != nil { + if id, err := verifier.GetPeerIdentity(tlsConn); err == nil && id != nil { + logger.V(3).Debugf("set conn %p kRemoteIdentity = %v", tlsConn, id.ToSpiffe()) + rid = id + } else if err != nil { + return "", fmt.Errorf("conn %p RemoteIdentity err: %v", tlsConn, err) + } + } else { + return "", fmt.Errorf("conn %p: no verifier", tlsConn) + } + } + + if rid == nil { + return "", errors.New("failed to get remote identity") + } + + log.StateFromContext(r.Context()).Set(kRemoteIdentity, rid) + + err := h.verifier.MatchRemoteIdentity(rid) + if err != nil { + return "", NewHTTPErrorFromError(err, http.StatusUnauthorized) + } + + return rid.ToSpiffe().String(), nil +} + +func (h *clientCertAuthorizationHandler) Reject(w http.ResponseWriter, r *http.Request) { + var rid *mtls.RemoteIdentity + if id, ok := log.StateFromContext(r.Context()).Get(kRemoteIdentity).(*mtls.RemoteIdentity); ok { + rid = id + } + + if h.verbose && rid != nil { + msg := fmt.Sprintf("access denied based on peer identity %q", + rid.ToSpiffe().String()) + http.Error(w, msg, http.StatusUnauthorized) + } else { + http.Error(w, "The provided client identity does not have access to this resource.", http.StatusUnauthorized) + } +} + +func init() { + AddAuthorizer(clientCertAuthorizerName, &clientCertAuthorizer{}) +} diff --git a/http/route_action_saml.go b/http/authorizer_saml.go similarity index 71% rename from http/route_action_saml.go rename to http/authorizer_saml.go index 9589651..01dea94 100644 --- a/http/route_action_saml.go +++ b/http/authorizer_saml.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "errors" "fmt" "math/big" "net/http" @@ -18,10 +19,10 @@ import ( "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" + "gopkg.in/yaml.v3" "go.fuhry.dev/runtime/mtls/certutil" "go.fuhry.dev/runtime/utils/hashset" - "gopkg.in/yaml.v3" ) type SAMLServiceProvider struct { @@ -40,81 +41,107 @@ type SAMLServiceProvider struct { mw map[string]*samlsp.Middleware } -type samlAction struct { +type samlAuthorizer struct{} + +var _ Authorizer = &samlAuthorizer{} + +type samlAuthorizationHandler struct { sp *SAMLServiceProvider requireAuth bool - usernameHeader string preserveCookies bool + remapHeaders map[string]string } -var samlAttributeReplaceRegexp = regexp.MustCompile(`[^a-z0-9]+`) -var restrictedHeaders = hashset.FromSlice([]string{"on-behalf-of"}) +var _ AuthorizationHandler = &samlAuthorizationHandler{} -func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - logger := LoggerFromContext(r.Context()) +var ( + samlAttributeReplaceRegexp = regexp.MustCompile(`[^a-z0-9]+`) + restrictedHeaders = hashset.FromSlice([]string{"on-behalf-of"}) +) - // if request was already authorized, do nothing - if authz := AuthorizationFromContext(r.Context()); authz != nil { - logger.V(3).Debugf("req already authorized for %q by %q, skip saml", authz.Principal, authz.Authorizer) - next(w, r) - return +func (a *samlAuthorizer) New(node *yaml.Node) (AuthorizationHandler, error) { + var rawNode struct { + SP *struct { + *SAMLServiceProvider + Require string `yaml:"require"` + PreserveCookies bool `yaml:"preserve_cookies"` + RemapHeaders map[string]string `yaml:"remap_headers"` + } `yaml:"saml,omitempty"` } - // ensure client isn't trying to inject saml-related headers - if err := sa.checkRequest(r); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return + err := node.Decode(&rawNode) + if err != nil { + return nil, err } + var spConfig *SAMLServiceProvider + require := true + preserveCookies := false + if rawNode.SP != nil { + require, err = strconv.ParseBool(rawNode.SP.Require) + if err != nil { + return nil, err + } + spConfig = rawNode.SP.SAMLServiceProvider + preserveCookies = rawNode.SP.PreserveCookies + } + + var remapHeaders map[string]string = nil + if rawNode.SP != nil && rawNode.SP.RemapHeaders != nil { + remapHeaders = rawNode.SP.RemapHeaders + } + + sa := &samlAuthorizationHandler{ + sp: spConfig, + requireAuth: require, + remapHeaders: remapHeaders, + preserveCookies: preserveCookies, + } + + return sa, nil +} + +func (sa *samlAuthorizationHandler) getProvider(r *http.Request) (*samlsp.Middleware, error) { sp := sa.sp if sp == nil { sp = serverDefaultSamlConfig(r.Context()) } if sp == nil { - http.Error(w, "SAML auth requested but no SP config present", - http.StatusInternalServerError) + return nil, errors.New("saml auth requested but no SP config present") } - provider, err := sp.getMiddleware(r.Host) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return sp.getMiddleware(r.Host) +} + +func (sa *samlAuthorizationHandler) Check(w http.ResponseWriter, r *http.Request) (string, error) { + logger := LoggerFromContext(r.Context()) + + if authzn := AuthorizationFromContext(r.Context()); authzn != nil { + if authzn.Authorizer == "saml" { + return authzn.Principal, nil + } } - if r.URL.Path == "/saml/acs" { - provider.ServeACS(w, r) - return + // ensure client isn't trying to inject saml-related headers + if err := sa.checkRequest(r); err != nil { + return "", NewHTTPErrorFromError(err, http.StatusBadRequest) + } + + provider, err := sa.getProvider(r) + if err != nil { + return "", err } session, sessionErr := provider.Session.GetSession(r) if sessionErr != nil && sessionErr != samlsp.ErrNoSession { - http.Error(w, sessionErr.Error(), http.StatusBadRequest) - return + return "", NewHTTPErrorFromError(sessionErr, http.StatusBadRequest) } if sessionErr == samlsp.ErrNoSession && sa.requireAuth { - // cleanup previous saml cookies, because these really pile up - logger.V(3).Debugf("request tracker is a %T", provider.RequestTracker) - if t, ok := provider.RequestTracker.(*samlsp.CookieRequestTracker); ok { - sa.cleanCookies(w, r, t) - } - if r.Header.Get("sec-fetch-mode") == "cors" || r.Header.Get("sec-fetch-mode") == "websocket" { - // XHRs should not be redirected to auth as this creates a ton of cookie spam - errMsg := "SAML session required" - if r.Header.Get("accept") == "application/json" { - w.Header().Set("content-type", "application/json") - errMsg = `{"error": "` + errMsg + `"}` - } - http.Error(w, errMsg, http.StatusUnauthorized) - return - } - logger.V(3).Debugf("route requires a valid session, redirecting") - - provider.HandleStartAuthFlow(w, r) - return + return "", nil } - var principal string + principal := "anonymous" if swa, ok := session.(samlsp.SessionWithAttributes); ok { attrs := swa.GetAttributes() if uid := attrs.Get("uid"); uid != "" { @@ -123,18 +150,18 @@ func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.H } if jwts, ok := session.(samlsp.JWTSessionClaims); ok { - r.Header.Set("x-saml-audience", jwts.StandardClaims.Audience) + r.Header.Set(sa.headerName("x-saml-audience"), jwts.StandardClaims.Audience) logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-audience", jwts.StandardClaims.Audience) iat := strconv.FormatInt(jwts.StandardClaims.IssuedAt, 10) - r.Header.Set("x-saml-issued-at", iat) + r.Header.Set(sa.headerName("x-saml-issued-at"), iat) logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-issued-at", iat) eat := strconv.FormatInt(jwts.StandardClaims.ExpiresAt, 10) - r.Header.Set("x-saml-expires-at", eat) + r.Header.Set(sa.headerName("x-saml-expires-at"), eat) logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-expires-at", eat) - r.Header.Set("x-saml-subject", jwts.StandardClaims.Subject) + r.Header.Set(sa.headerName("x-saml-subject"), jwts.StandardClaims.Subject) logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-subject", jwts.StandardClaims.Subject) if principal == "" { principal = jwts.StandardClaims.Subject @@ -143,20 +170,18 @@ func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.H for attr, values := range jwts.Attributes { headerName := fmt.Sprintf("x-saml-%s", samlAttributeReplaceRegexp.ReplaceAllString(strings.ToLower(attr), "-")) + headerName = sa.headerName(headerName) headerValue := strings.Join(values, ", ") logger.V(3).Debugf("setting origin request header: %s: %s", headerName, headerValue) r.Header.Set(headerName, headerValue) } - oboHeader := sa.usernameHeader - if oboHeader == "" { - oboHeader = "on-behalf-of" - } + oboHeader := sa.headerName("on-behalf-of") logger.V(3).Debugf("setting origin request header: %s: %s", oboHeader, principal) r.Header.Set(oboHeader, principal) } else { - r.Header.Set("x-saml-anonymous-auth", "1") + r.Header.Set(sa.headerName("x-saml-anonymous-auth"), "1") } // don't forward saml cookies to the origin unless PreserveCookies is set @@ -173,18 +198,59 @@ func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.H r = newReq } - SetAuthorizationContext(r.Context(), &Authorization{ - Authorizer: "saml", - Principal: principal, - }) + return principal, nil +} - next(w, r) +func (sa *samlAuthorizationHandler) headerName(in string) string { + if sa.remapHeaders != nil { + if altName, ok := sa.remapHeaders[in]; ok { + return altName + } + } + + return in +} + +func (sa *samlAuthorizationHandler) Reject(w http.ResponseWriter, r *http.Request) { + logger := LoggerFromContext(r.Context()) + + provider, err := sa.getProvider(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if r.URL.Path == "/saml/acs" { + provider.ServeACS(w, r) + return + } + + // cleanup previous saml cookies, because these really pile up + if t, ok := provider.RequestTracker.(*samlsp.CookieRequestTracker); ok { + sa.cleanCookies(w, r, t) + } + + logger.V(3).Debugf("request tracker is a %T", provider.RequestTracker) + if r.Header.Get("sec-fetch-mode") == "cors" || r.Header.Get("sec-fetch-mode") == "websocket" { + // XHRs should not be redirected to auth as this creates a ton of cookie spam + errMsg := "SAML session required" + if r.Header.Get("accept") == "application/json" { + w.Header().Set("content-type", "application/json") + errMsg = `{"error": "` + errMsg + `"}` + } + http.Error(w, errMsg, http.StatusUnauthorized) + return + } + logger.V(3).Debugf("route requires a valid session, redirecting") + + provider.HandleStartAuthFlow(w, r) } -func (sa *samlAction) checkRequest(r *http.Request) error { - for k, _ := range r.Header { +func (sa *samlAuthorizationHandler) checkRequest(r *http.Request) error { + for k := range r.Header { k = strings.ToLower(k) - if strings.HasPrefix(k, "x-saml-") || restrictedHeaders.Contains(k) { + altName, remapped := sa.remapHeaders[k] + if strings.HasPrefix(k, "x-saml-") || restrictedHeaders.Contains(k) || (remapped && k == altName) { return fmt.Errorf("downstream attempted to overwrite restricted header: %q", k) } } @@ -201,7 +267,7 @@ func (sa *samlAction) checkRequest(r *http.Request) error { // // To mitigate this, we clear any existing saml cookies whenever we send an auth redirect or reject // an XHR with a 401. -func (sa *samlAction) cleanCookies(w http.ResponseWriter, r *http.Request, t *samlsp.CookieRequestTracker) { +func (sa *samlAuthorizationHandler) cleanCookies(w http.ResponseWriter, r *http.Request, t *samlsp.CookieRequestTracker) { for _, cookie := range r.Cookies() { if strings.HasPrefix(cookie.Name, "saml_") { logger := LoggerFromContext(r.Context()) @@ -348,18 +414,6 @@ func (sp *SAMLServiceProvider) newMiddleware(host string) (*samlsp.Middleware, e }) } -func samlInitHook(ctx context.Context, node *yaml.Node) (context.Context, error) { - var samlConfig struct { - SP *SAMLServiceProvider `yaml:"saml"` - } - - if err := node.Decode(&samlConfig); err == nil && samlConfig.SP != nil { - ctx = context.WithValue(ctx, kSamlDefaults, samlConfig.SP) - } - - return ctx, nil -} - func serverDefaultSamlConfig(ctx context.Context) *SAMLServiceProvider { v := ctx.Value(kSamlDefaults) if c, ok := v.(*SAMLServiceProvider); ok { @@ -368,43 +422,19 @@ func serverDefaultSamlConfig(ctx context.Context) *SAMLServiceProvider { return nil } -func samlActionFromRouteYaml(node *yaml.Node) (RouteAction, error) { - var rawNode struct { - SP *struct { - *SAMLServiceProvider - Require string `yaml:"require"` - PreserveCookies bool `yaml:"preserve_cookies"` - } `yaml:"saml,omitempty"` - Auth string `yaml:"auth"` - } - - err := node.Decode(&rawNode) - if err != nil || rawNode.Auth != "saml" { - return nil, nil - } - - var spConfig *SAMLServiceProvider - require := true - preserveCookies := false - if rawNode.SP != nil { - require, err = strconv.ParseBool(rawNode.SP.Require) - if err != nil { - return nil, err - } - spConfig = rawNode.SP.SAMLServiceProvider - preserveCookies = rawNode.SP.PreserveCookies +func samlInitHook(ctx context.Context, node *yaml.Node) (context.Context, error) { + var samlConfig struct { + SP *SAMLServiceProvider `yaml:"saml"` } - sa := &samlAction{ - sp: spConfig, - requireAuth: require, - preserveCookies: preserveCookies, + if err := node.Decode(&samlConfig); err == nil && samlConfig.SP != nil { + ctx = context.WithValue(ctx, kSamlDefaults, samlConfig.SP) } - return sa, nil + return ctx, nil } func init() { + AddAuthorizer("saml", &samlAuthorizer{}) AddServerInitHook(samlInitHook) - AddRouteParseFunc(samlActionFromRouteYaml) } diff --git a/http/http.go b/http/http.go index b65b019..0025ff1 100644 --- a/http/http.go +++ b/http/http.go @@ -3,6 +3,7 @@ package http import ( "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "net" @@ -10,8 +11,10 @@ import ( "os" "regexp" "strings" + "sync" "go.fuhry.dev/runtime/mtls" + "go.fuhry.dev/runtime/mtls/certutil" "go.fuhry.dev/runtime/utils/log" ) @@ -20,11 +23,23 @@ type HTTPVirtualHost struct { Routes []*Route `yaml:"routes"` Certificate string `yaml:"cert"` + MtlsID string `yaml:"mtls_id"` + Verify *string `yaml:"verify"` + RequireCert *string `yaml:"require_cert"` TrustUpstreamRequestID *bool `yaml:"trust_upstream_request_id"` - tlsServer *http.Server + tlsServer *http.Server + tlsVerifier mtls.MTLSPeerVerifier + swappableTlsConfig *hotSwappableTlsConfig } +type HTTPError struct { + StatusCode int + Message string +} + +var _ error = &HTTPError{} + func (v *HTTPVirtualHost) Serve(ctx context.Context, sniServer *SNIListener) (func() error, error) { server, err := v.NewHTTPServerWithContext(ctx) if err != nil { @@ -46,6 +61,91 @@ func (v *HTTPVirtualHost) Serve(ctx context.Context, sniServer *SNIListener) (fu }, nil } +func (h *HTTPVirtualHost) vhostIdentity() (mtls.Identity, error) { + if h.Certificate != "" && h.MtlsID != "" { + log.Default().WithPrefix("HTTPVirtualHost").Critical("ErrSSLCertAndMtlsIDMutex") + return nil, ErrSSLCertAndMtlsIDMutex + } + + var id mtls.Identity + if h.Certificate != "" { + id = mtls.NewSSLCertificate(h.Certificate) + } else if h.MtlsID != "" { + id = mtls.NewServiceIdentity(h.MtlsID) + } else { + // use parent listener identity + return nil, nil + } + + return id, nil +} + +func (h *HTTPVirtualHost) newTlsConfig(ctx context.Context) (*tls.Config, mtls.MTLSPeerVerifier, error) { + var verifier mtls.MTLSPeerVerifier + + id, err := h.vhostIdentity() + if err != nil { + return nil, nil, err + } + if id == nil { + return nil, nil, nil + } + + config, err := id.TlsConfig(ctx) + if err != nil { + return nil, nil, err + } + + if h.Verify != nil && *h.Verify != verifyTokenNone { + clientAuth := tls.RequireAndVerifyClientCert + + if h.RequireCert != nil { + switch *h.RequireCert { + case tls.RequestClientCert.String(): + clientAuth = tls.RequestClientCert + case tls.RequireAndVerifyClientCert.String(): + clientAuth = tls.RequireAndVerifyClientCert + case tls.RequireAnyClientCert.String(): + clientAuth = tls.RequireAnyClientCert + case tls.VerifyClientCertIfGiven.String(): + clientAuth = tls.VerifyClientCertIfGiven + case tls.NoClientCert.String(): + return nil, nil, fmt.Errorf("invalid value for require_cert %q; unset \"verify\" to disable client cert", *h.RequireCert) + default: + return nil, nil, fmt.Errorf("invalid value for require_cert %q; valid values", *h.RequireCert) + } + } + config.ClientAuth = clientAuth + if *h.Verify == verifyTokenMtls { + // verify with global mtls root CA + verifier = mtls.NewPeerNameVerifier() + if err = verifier.ConfigureServer(config); err != nil { + return nil, nil, err + } + verifier.AllowFrom(mtls.All) + } else { + certs, err := certutil.LoadCertificatesFromPEM(*h.Verify) + if err != nil { + return nil, nil, fmt.Errorf("failed to load client CAs from %s: %v", *h.Verify, err) + } + + pool := x509.NewCertPool() + for _, cert := range certs { + pool.AddCert(cert) + } + + config.ClientCAs = pool + } + } else { + config.ClientAuth = tls.NoClientCert + config.VerifyPeerCertificate = nil + config.VerifyConnection = nil + config.RootCAs = nil + } + + return config, verifier, nil +} + // NewHTTPServerWithContext creates an http.Server using the proxy's virtual host // and other settings. func (v *HTTPVirtualHost) NewHTTPServerWithContext(ctx context.Context) (*http.Server, error) { @@ -54,25 +154,39 @@ func (v *HTTPVirtualHost) NewHTTPServerWithContext(ctx context.Context) (*http.S lm := log.NewLoggingMiddlewareWithLogger( http.HandlerFunc(v.handle), - logger.AppendPrefix(".access").WithLevel(log.INFO)) + logger.AppendPrefix(".access").WithLevel(log.INFO), + ) lm.ExtraFunc = v.injectLogAttrs server := &http.Server{ Addr: "", BaseContext: func(l net.Listener) context.Context { - return context.WithValue(serverCtx, kListenAddr, l.Addr()) + ctx := context.WithValue(serverCtx, kListenAddr, l.Addr()) + ctx = context.WithValue(ctx, kVirtualHost, v) + return ctx + }, + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + if tlsConn, ok := c.(*tls.Conn); ok { + ctx = context.WithValue(ctx, kTlsConn, tlsConn) + } + + return ctx }, Handler: lm.HandlerFunc(), } - if v.Certificate != "" { - cert := mtls.NewSSLCertificate(v.Certificate) - tlsConfig, err := cert.TlsConfig(serverCtx) - if err != nil { - return nil, err - } + tlsConfig, verifier, err := v.newTlsConfig(serverCtx) + if err != nil { + return nil, err + } + if verifier != nil { + v.tlsVerifier = verifier + } + + if tlsConfig != nil { tlsConfig.NextProtos = []string{"h2"} - server.TLSConfig = tlsConfig + + server.TLSConfig, v.swappableTlsConfig = newHotSwappableTlsConfig(tlsConfig) } return server, nil @@ -112,7 +226,7 @@ func (v *HTTPVirtualHost) handle(w http.ResponseWriter, r *http.Request) { } func (v *HTTPVirtualHost) swapTlsConfig(c *tls.Config) { - v.tlsServer.TLSConfig = c + v.swappableTlsConfig.swap(c) } func vhostGlobToRegexp(name string) (*regexp.Regexp, error) { @@ -150,6 +264,26 @@ func (v *HTTPVirtualHost) fulfill(w http.ResponseWriter, r *http.Request, routes } if match { + if len(route.Authorizers) > 0 { + for _, authorizer := range route.Authorizers { + if princ, err := authorizer.handler.Check(w, r); princ != "" && err == nil { + SetAuthorizationContext(r.Context(), &Authorization{ + Authorizer: authorizer.name, + Principal: princ, + }) + break + } else if err != nil { + herr := NewHTTPErrorFromError(err, http.StatusInternalServerError) + herr.Write(w) + return + } + } + + if AuthorizationFromContext(r.Context()) == nil { + route.Authorizers[0].handler.Reject(w, r) + return + } + } if route.Action != nil { logger.V(3).Debugf("route has action %T, dispatching: %+v", route.Action, route.Action) next := http.NotFound @@ -165,6 +299,7 @@ func (v *HTTPVirtualHost) fulfill(w http.ResponseWriter, r *http.Request, routes http.Error(w, fmt.Sprintf("no action configured for route %s", route.Path.String()), http.StatusInternalServerError) + return } } } @@ -181,3 +316,126 @@ func (v *HTTPVirtualHost) injectLogAttrs(entry map[string]any, w http.ResponseWr entry["request_id"] = rid } } + +func (h *HTTPError) Error() string { + return fmt.Sprintf("HTTP %d %s\n%s", h.StatusCode, http.StatusText(h.StatusCode), h.Message) +} + +func (h *HTTPError) Write(w http.ResponseWriter) { + http.Error(w, h.Message, h.StatusCode) +} + +func NewHTTPError(statusCode int, msg string) *HTTPError { + return &HTTPError{ + StatusCode: statusCode, + Message: msg, + } +} + +func NewHTTPErrorFromError(err error, statusCode int) *HTTPError { + if h, ok := err.(*HTTPError); ok { + return h + } + + return NewHTTPError(statusCode, err.Error()) +} + +type hotSwappableTlsConfig struct { + inner *tls.Config + mu sync.RWMutex +} + +func (c *hotSwappableTlsConfig) GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.GetCertificate == nil { + return nil, nil + } + return c.inner.GetCertificate(chi) +} + +func (c *hotSwappableTlsConfig) GetClientCertificate(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.GetClientCertificate == nil { + return nil, nil + } + return c.inner.GetClientCertificate(cri) +} + +func (c *hotSwappableTlsConfig) GetConfigForClient(chi *tls.ClientHelloInfo) (*tls.Config, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.GetConfigForClient == nil { + return c.inner, nil + } + return c.inner.GetConfigForClient(chi) +} + +func (c *hotSwappableTlsConfig) UnwrapSession(identity []byte, cs tls.ConnectionState) (*tls.SessionState, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.UnwrapSession == nil { + return c.inner.DecryptTicket(identity, cs) + } + + return c.inner.UnwrapSession(identity, cs) +} + +func (c *hotSwappableTlsConfig) WrapSession(cs tls.ConnectionState, ss *tls.SessionState) ([]byte, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.WrapSession == nil { + return c.inner.EncryptTicket(cs, ss) + } + + return c.inner.WrapSession(cs, ss) +} + +func (c *hotSwappableTlsConfig) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.VerifyPeerCertificate == nil { + return nil + } + return c.inner.VerifyPeerCertificate(rawCerts, verifiedChains) +} + +func (c *hotSwappableTlsConfig) VerifyConnection(cs tls.ConnectionState) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.inner.VerifyConnection == nil { + return nil + } + + return c.inner.VerifyConnection(cs) +} + +func (c *hotSwappableTlsConfig) swap(n *tls.Config) { + c.mu.Lock() + defer c.mu.Unlock() + + c.inner = n +} + +func newHotSwappableTlsConfig(inner *tls.Config) (*tls.Config, *hotSwappableTlsConfig) { + hs := &hotSwappableTlsConfig{inner, sync.RWMutex{}} + + outer := inner.Clone() + outer.GetCertificate = hs.GetCertificate + outer.GetClientCertificate = hs.GetClientCertificate + outer.GetConfigForClient = hs.GetConfigForClient + outer.WrapSession = hs.WrapSession + outer.UnwrapSession = hs.UnwrapSession + outer.VerifyPeerCertificate = hs.VerifyPeerCertificate + outer.VerifyConnection = hs.VerifyConnection + + return outer, hs +} diff --git a/http/route_action_s3.go b/http/route_action_s3.go index 8659c1a..1e557a3 100644 --- a/http/route_action_s3.go +++ b/http/route_action_s3.go @@ -36,7 +36,7 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand mc, err := a.minioClient() if err != nil { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(fmt.Sprintf("failed to init minio client: %+v", err))) + fmt.Fprintf(w, "failed to init minio client: %+v", err) return } @@ -44,7 +44,7 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand reqPath, err = url.PathUnescape(reqPath) if err != nil { w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(fmt.Sprintf("failed decoding path: %+v", err))) + fmt.Fprintf(w, "failed decoding path: %+v", err) return } objPath := strings.Trim(strings.Join( @@ -52,7 +52,8 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand strings.Trim(a.ObjectPrefix, "/"), strings.Trim(reqPath, "/"), }, - "/"), "/") + "/", + ), "/") rc := http.StatusOK var rh *RangeHeader @@ -72,11 +73,11 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand r.Context(), a.BucketName, objPath, - minio.GetObjectOptions{}) - + minio.GetObjectOptions{}, + ) if err != nil { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(fmt.Sprintf("failed to GetObject %q: %+v", reqPath, err))) + fmt.Fprintf(w, "failed to GetObject %q: %+v", reqPath, err) return } @@ -84,7 +85,7 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand if err != nil { er := minio.ToErrorResponse(err) w.WriteHeader(coalesce(er.StatusCode, http.StatusInternalServerError)) - w.Write([]byte(fmt.Sprintf("failed to stat object %q: %+v", objPath, err))) + fmt.Fprintf(w, "failed to stat object %q: %+v", objPath, err) return } @@ -109,14 +110,15 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand } var seek int64 - var size = stat.Size + size := stat.Size if rh != nil { if rh.SuffixLength > 0 { if rh.SuffixLength > uint64(stat.Size) { http.Error(w, fmt.Sprintf( "requested suffix of %d bytes exceeds file size of %d bytes", - rh.SuffixLength, stat.Size), + rh.SuffixLength, stat.Size, + ), http.StatusRequestedRangeNotSatisfiable) return @@ -137,7 +139,8 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand http.Error(w, fmt.Sprintf( "requested range %d-%d exceeds file size of %d bytes", - rng.Start, rng.End, stat.Size), + rng.Start, rng.End, stat.Size, + ), http.StatusRequestedRangeNotSatisfiable) return } @@ -151,7 +154,8 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand LoggerFromContext(r.Context()).V(3).Debugf( "satisfiable range is %d bytes long, seeking to byte %d: %s", - size, seek, respRange) + size, seek, respRange, + ) } else { http.Error(w, "multiple ranges are not supported", diff --git a/http/server.go b/http/server.go index 18ae75f..2c4d081 100644 --- a/http/server.go +++ b/http/server.go @@ -15,11 +15,12 @@ import ( "sync" "time" + "gopkg.in/yaml.v3" + "go.fuhry.dev/runtime/mtls" "go.fuhry.dev/runtime/utils/hashset" "go.fuhry.dev/runtime/utils/log" "go.fuhry.dev/runtime/utils/stringmatch" - "gopkg.in/yaml.v3" ) type serverCtxVar int @@ -28,9 +29,19 @@ type RouteAction interface { Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) } +type Authorizer interface { + New(*yaml.Node) (AuthorizationHandler, error) +} + +type AuthorizationHandler interface { + Check(w http.ResponseWriter, r *http.Request) (string, error) + Reject(w http.ResponseWriter, r *http.Request) +} + type Route struct { - Path stringmatch.StringMatcher - Action RouteAction + Path stringmatch.StringMatcher + Authorizers []*authznEntry + Action RouteAction } type NameMatcher interface { @@ -62,6 +73,9 @@ type Listener struct { ProxyProtocol bool `yaml:"proxy_protocol"` InsecureAddr string `yaml:"listen_insecure"` Certificate string `yaml:"cert"` + MtlsID string `yaml:"mtls_id"` + Verify string `yaml:"verify"` + RequireCert string `yaml:"require_cert"` TrustUpstreamRequestID bool `yaml:"trust_upstream_request_id"` DrainTime time.Duration `yaml:"drain_time"` VirtualHosts []VirtualHost `yaml:"-"` @@ -83,8 +97,15 @@ type Authorization struct { Principal string } -type initHook func(context.Context, *yaml.Node) (context.Context, error) -type routeParseFunc func(*yaml.Node) (RouteAction, error) +type authznEntry struct { + name string + handler AuthorizationHandler +} + +type ( + initHook func(context.Context, *yaml.Node) (context.Context, error) + routeParseFunc func(*yaml.Node) (RouteAction, error) +) const ( kLogger serverCtxVar = iota @@ -94,6 +115,8 @@ const ( kSamlDefaults kRequestID kAuthorization + kTlsConn + kRemoteIdentity ) const ( @@ -102,18 +125,26 @@ const ( hostnameWildcardSingle = `[A-Za-z0-9-]+` hostnameWildcardMulti = `[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*` + + verifyTokenMtls = "mtls" + verifyTokenNone = "none" ) const ( DefaultDrainTime = 30 * time.Second ) -var ErrServerClosed = http.ErrServerClosed -var randSrc = rand.New(rand.NewSource(time.Now().UnixNano())) -var portSpec = regexp.MustCompile(":[0-9]{1,5}$") -var domainComponentSpec = regexp.MustCompile("^" + hostnameWildcardSingle + "$") -var initHooks []initHook -var routeParseFuncs []routeParseFunc +var ( + ErrServerClosed = http.ErrServerClosed + ErrSSLCertAndMtlsIDMutex = errors.New("cert and mtls_id options are mutually exclusive") + randSrc = rand.New(rand.NewSource(time.Now().UnixNano())) + portSpec = regexp.MustCompile(":[0-9]{1,5}$") + domainComponentSpec = regexp.MustCompile("^" + hostnameWildcardSingle + "$") + initHooks []initHook + routeParseFuncs []routeParseFunc + authorizers map[string]Authorizer + authorizersOnce sync.Once +) func AddServerInitHook(hook initHook) { initHooks = append(initHooks, hook) @@ -123,6 +154,19 @@ func AddRouteParseFunc(rpf routeParseFunc) { routeParseFuncs = append(routeParseFuncs, rpf) } +func AddAuthorizer(name string, a Authorizer) { + authorizersOnce.Do(func() { + authorizers = make(map[string]Authorizer) + }) + + if _, ok := authorizers[name]; ok { + log.Panicf("authorizer %q already registered", name) + return + } + + authorizers[name] = a +} + func NewServer() *Server { return NewServerWithContext(context.Background()) } @@ -138,7 +182,8 @@ func NewServerWithContext(ctx context.Context) *Server { // UnmarshalYAML implements yaml.Unmarshaler func (r *Route) UnmarshalYAML(node *yaml.Node) error { var rawNode struct { - Path *stringmatch.MatchRule `yaml:"path"` + Path *stringmatch.MatchRule `yaml:"path"` + Authorizers []string `yaml:"auth"` } if err := node.Decode(&rawNode); err != nil { @@ -155,6 +200,25 @@ func (r *Route) UnmarshalYAML(node *yaml.Node) error { return errors.New("error unmarshaling route: path must be specified") } + if len(rawNode.Authorizers) > 0 { + for _, azName := range rawNode.Authorizers { + authorizer, ok := authorizers[azName] + if !ok { + return fmt.Errorf("unknown authorizer: %q", azName) + } + + handler, err := authorizer.New(node) + if err != nil { + return fmt.Errorf("error initializing authorizer %q: %v", azName, err) + } + + r.Authorizers = append(r.Authorizers, &authznEntry{ + name: azName, + handler: handler, + }) + } + } + for _, rpf := range routeParseFuncs { action, err := rpf(node) if err != nil { @@ -176,6 +240,9 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error { ProxyProtocol bool `yaml:"proxy_protocol"` InsecureAddr string `yaml:"listen_insecure"` Certificate string `yaml:"cert"` + MtlsID string `yaml:"mtls_id"` + Verify string `yaml:"verify"` + RequireCert string `yaml:"require_cert"` TrustUpstreamRequestID bool `yaml:"trust_upstream_request_id"` DrainTime string `yaml:"drain_time"` } @@ -205,6 +272,9 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error { l.ProxyProtocol = rawNode.ProxyProtocol l.InsecureAddr = rawNode.InsecureAddr l.Certificate = rawNode.Certificate + l.MtlsID = rawNode.MtlsID + l.Verify = rawNode.Verify + l.RequireCert = rawNode.RequireCert l.TrustUpstreamRequestID = rawNode.TrustUpstreamRequestID if rawNode.DrainTime != "" { @@ -217,6 +287,10 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error { l.DrainTime = DefaultDrainTime } + if rawNode.Certificate != "" && rawNode.MtlsID != "" { + return ErrSSLCertAndMtlsIDMutex + } + coveredVhosts := hashset.NewHashSet[string]() if httpNode.HTTPVirtualHosts != nil { @@ -227,8 +301,18 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error { if vhost.BaseVirtualHost == nil { vhost.BaseVirtualHost = &BaseVirtualHost{} } + if vhost.Verify == nil && l.Verify != "" { + vhost.Verify = &l.Verify + } + if vhost.RequireCert == nil && l.RequireCert != "" { + vhost.RequireCert = &l.RequireCert + } coveredVhosts.Add(name) vhost.Aliases = append(vhost.Aliases, name) + if vhost.Certificate == "" && vhost.MtlsID == "" { + vhost.Certificate = l.Certificate + vhost.MtlsID = l.MtlsID + } l.VirtualHosts = append(l.VirtualHosts, vhost) } } @@ -245,7 +329,8 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error { if coveredVhosts.Contains(name) { return fmt.Errorf( "virtual host %q contains both http routes and a SNI backend, this is not allowed", - name) + name, + ) } coveredVhosts.Add(name) @@ -257,12 +342,30 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error { if coveredVhosts.Len() < len(httpNode.HTTPVirtualHosts) { return fmt.Errorf( "%d virtual hosts are unconfigured, specify either routes for HTTP or tcp_backend "+ - "for SNI proxying", len(httpNode.HTTPVirtualHosts)-coveredVhosts.Len()) + "for SNI proxying", len(httpNode.HTTPVirtualHosts)-coveredVhosts.Len(), + ) } return nil } +func (l *Listener) serverIdentity() (mtls.Identity, error) { + if l.Certificate != "" && l.MtlsID != "" { + return nil, ErrSSLCertAndMtlsIDMutex + } + + var id mtls.Identity + if l.Certificate != "" { + id = mtls.NewSSLCertificate(l.Certificate) + } else if l.MtlsID != "" { + id = mtls.NewServiceIdentity(l.MtlsID) + } else { + return nil, nil + } + + return id, nil +} + // UnmarshalYAML implements yaml.Unmarshaler func (s *Server) UnmarshalYAML(node *yaml.Node) error { var lc struct { @@ -290,8 +393,9 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { for i, vhost := range listener.VirtualHosts { switch vhost := vhost.(type) { case *HTTPVirtualHost: - if vhost.Certificate == "" { + if vhost.Certificate == "" && vhost.MtlsID == "" { vhost.Certificate = listener.Certificate + vhost.MtlsID = listener.MtlsID } if vhost.TrustUpstreamRequestID == nil { vhost.TrustUpstreamRequestID = &listener.TrustUpstreamRequestID @@ -304,7 +408,7 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { if err := vhost.precompileAliases(); err != nil { return fmt.Errorf("bootstrapping sni vhost %d: %v", i, err) } - log.Default().Debugf("sni vhost %+v bootatrapped", vhost.Names()) + log.Default().Debugf("sni vhost %+v bootstrapped", vhost.Names()) default: return fmt.Errorf("unsupported vhost type: %T", vhost) } @@ -335,10 +439,20 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { return fmt.Errorf("hot reload refused: listener %d: toggling proxy protocol is not supported", i) } + oldId, err := oldL.serverIdentity() + if err != nil { + return fmt.Errorf("unable to get old listener server identity: %v", err) + } + newId, err := newL.serverIdentity() + if err != nil { + return fmt.Errorf("unable to get new listener server identity: %v", err) + } + oldL.TrustUpstreamRequestID = newL.TrustUpstreamRequestID oldL.ExternalPort = newL.ExternalPort oldL.DrainTime = newL.DrainTime oldL.Certificate = newL.Certificate + oldL.MtlsID = newL.MtlsID // virtual hosts that need to be started or closed var ( @@ -370,25 +484,72 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { continue } + // copy all properties from the new vhost to the old one switch oldVh := vh.(type) { case *HTTPVirtualHost: newVh := match.(*HTTPVirtualHost) oldVh.Routes = newVh.Routes oldVh.TrustUpstreamRequestID = newVh.TrustUpstreamRequestID - if oldVh.Certificate != newVh.Certificate { - newCert := mtls.NewSSLCertificate(newVh.Certificate) - if tlsConfig, err := newCert.TlsConfig(listenerCtx); err == nil { + oldVhostId, err := oldVh.vhostIdentity() + logger := LoggerFromContext(listenerCtx) + if err != nil { + logger.Errorf("error getting identity of old vhost %v: %v", vh.Names(), err) + break + } + newVhostId, err := newVh.vhostIdentity() + if err != nil { + logger.Errorf("error getting identity of new vhost %v: %v", vh.Names(), err) + break + } + if oldVhostId == nil { + if oldId == nil { + return errors.New("neither cert nor mtls_id specified for old server") + } + oldVhostId = oldId + } + if newVhostId == nil { + if oldId == nil { + return errors.New("neither cert nor mtls_id specified for new server") + } + newVhostId = newId + } + + verifyChanged := false + if newVh.Verify != nil { + if oldVh.Verify == nil || *oldVh.Verify != *newVh.Verify { + oldVh.Verify = newVh.Verify + verifyChanged = true + } + } else if newL.Verify != verifyTokenNone && newL.Verify != "" { + if oldVh.Verify != nil || (*oldVh.Verify != newL.Verify) { + oldVh.Verify = &newL.Verify + verifyChanged = true + } + } else { + if oldVh.Verify != nil && *oldVh.Verify != verifyTokenNone { + oldVh.Verify = nil + verifyChanged = true + } + } + + if !oldVhostId.Equals(newVhostId) || verifyChanged { + if tlsConfig, tlsVerifier, err := newVh.newTlsConfig(listenerCtx); err == nil { oldVh.swapTlsConfig(tlsConfig) + oldVh.Certificate = newVh.Certificate + oldVh.MtlsID = newVh.MtlsID + oldVh.tlsVerifier = tlsVerifier } else { - LoggerFromContext(listenerCtx).Errorf( + logger.Errorf( "vhost %+v: error loading new ssl certificate %q: %v", - oldVh.Names(), newVh.Certificate, err) + oldVh.Names(), newVh.Certificate, err, + ) } } - LoggerFromContext(listenerCtx).Debugf( + logger.Debugf( "loaded %d new routes for HTTP vhost %+v", - len(oldVh.Routes), oldVh.Names()) + len(oldVh.Routes), oldVh.Names(), + ) case *SNIVirtualHost: newVh := match.(*SNIVirtualHost) oldVh.Backend = newVh.Backend @@ -410,15 +571,16 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { if err != nil { LoggerFromContext(listenerCtx).Warningf( "failed to start vhost(%T, %+v): %v", - vhost, vhost.Names(), err) + vhost, vhost.Names(), err, + ) continue } oldL.wg.Add(1) - go (func() { + go func() { defer oldL.wg.Done() serveFunc() - })() + }() oldL.VirtualHosts = append(oldL.VirtualHosts, vhost) @@ -436,7 +598,8 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { if err := vhost.Shutdown(shutdownCtx); err != nil { LoggerFromContext(listenerCtx).Warningf( "failed to shutdown vhost(%T, %+v): %v", - vhost, vhost.Names(), err) + vhost, vhost.Names(), err, + ) } LoggerFromContext(listenerCtx).Debugf("shut down removed vhost %+v", vhost.Names()) @@ -467,17 +630,17 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error { func (s *Server) ListenAndServeTLS() (err error) { s.servingTLS = true - defer (func() { s.servingTLS = false })() + defer func() { s.servingTLS = false }() wg := &sync.WaitGroup{} for _, listener := range s.Listeners { listenerCtx := context.WithValue(s.Context, kListener, listener) wg.Add(1) - go (func() { + go func() { defer wg.Done() err = listener.ListenAndServeSNI(listenerCtx) - })() + }() } wg.Wait() @@ -486,7 +649,7 @@ func (s *Server) ListenAndServeTLS() (err error) { func (s *Server) ListenAndServe() (err error) { s.servingHTTP = true - defer (func() { s.servingHTTP = false })() + defer func() { s.servingHTTP = false }() wg := &sync.WaitGroup{} for _, listener := range s.Listeners { @@ -514,10 +677,10 @@ func (s *Server) ListenAndServe() (err error) { listener.httpServer = server wg.Add(1) - go (func() { + go func() { defer wg.Done() err = server.Serve(netListener) - })() + }() } wg.Wait() @@ -531,25 +694,25 @@ func (s *Server) Shutdown(shutdownCtx context.Context) error { if listener.httpServer != nil { wg.Add(1) - go (func() { + go func() { defer wg.Done() err = listener.httpServer.Shutdown(shutdownCtx) - })() + }() } if listener.sniServer != nil { wg.Add(1) - go (func() { + go func() { defer wg.Done() err = listener.sniServer.Close() - })() + }() for _, vhost := range listener.VirtualHosts { wg.Add(1) - go (func() { + go func() { defer wg.Done() err = vhost.Shutdown(shutdownCtx) - })() + }() } } } @@ -597,17 +760,17 @@ func (l *Listener) ListenAndServeSNI(ctx context.Context) (err error) { } l.wg.Add(1) - go (func() { + go func() { defer l.wg.Done() serveFunc() - })() + }() } l.wg.Add(1) - go (func() { + go func() { defer l.wg.Done() err = l.sniServer.Serve() - })() + }() return err } @@ -620,7 +783,8 @@ func (l *Listener) NewHTTPSRedirectorWithContext(ctx context.Context) *http.Serv lm := log.NewLoggingMiddlewareWithLogger( http.HandlerFunc(l.handleRedirectToHTTPS), - logger.AppendPrefix(".access").WithLevel(log.INFO)) + logger.AppendPrefix(".access").WithLevel(log.INFO), + ) server := &http.Server{ Addr: l.InsecureAddr, @@ -749,6 +913,15 @@ func LoggerFromContext(ctx context.Context) log.Logger { return nil } +func VirtualHostFromContext(ctx context.Context) VirtualHost { + vh := ctx.Value(kVirtualHost) + if vh, ok := vh.(VirtualHost); ok { + return vh + } + + return nil +} + func AuthorizationFromContext(ctx context.Context) *Authorization { a := log.StateFromContext(ctx).Get(kAuthorization) if authorization, ok := a.(*Authorization); ok { diff --git a/http/sni_listener.go b/http/sni_listener.go index df33671..901fc64 100644 --- a/http/sni_listener.go +++ b/http/sni_listener.go @@ -80,11 +80,13 @@ func (l *SNIListener) Serve() error { l.logger.Errorf( "permanent error calling Accept(), propagating to vhosts: %v", - netErr) + netErr, + ) } else { l.logger.Errorf( "non-net.Error of type %T calling Accept(), propagating to vhosts: %v", - err, err) + err, err, + ) } l.vhostsMu.Lock() defer l.vhostsMu.Unlock() @@ -138,7 +140,8 @@ func (l *SNIListener) handle(conn net.Conn, logger log.Logger) { logger.V(1).Infof( "new conn matched vhost %+v for name %q, dispatch", vhost.matcher.Names(), - clientHello.ServerName) + clientHello.ServerName, + ) vhost.msg(childConn, nil) return } diff --git a/mtls/certutil/certutil.go b/mtls/certutil/certutil.go index 4f17681..c86237c 100644 --- a/mtls/certutil/certutil.go +++ b/mtls/certutil/certutil.go @@ -40,6 +40,42 @@ func LoadCertificatesFromPEM(path string) ([]*x509.Certificate, error) { } } +func SplitChain(certs []*x509.Certificate) (leaf *x509.Certificate, chain []*x509.Certificate) { + for _, cert := range certs { + if cert.IsCA { + chain = append(chain, cert) + } else { + leaf = cert + } + } + + return +} + +func SplitChainRoot(certs []*x509.Certificate) (leaf *x509.Certificate, chain []*x509.Certificate, root *x509.Certificate) { + for _, cert := range certs { + if cert.IsCA { + if IsSelfSigned(cert) { + root = cert + } else { + chain = append(chain, cert) + } + } else { + leaf = cert + } + } + + return +} + +func IsSelfSigned(cert *x509.Certificate) bool { + if cert.Issuer.ToRDNSequence().String() != cert.Subject.ToRDNSequence().String() { + return false + } + + return cert.CheckSignatureFrom(cert) == nil +} + func LoadPrivateKeyFromPEM(path string) (crypto.PrivateKey, error) { contents, err := os.ReadFile(path) if err != nil { diff --git a/mtls/identity.go b/mtls/identity.go index 4c158a7..57e3f7a 100644 --- a/mtls/identity.go +++ b/mtls/identity.go @@ -74,25 +74,9 @@ type stubIdentity struct { var _ Identity = &stubIdentity{} -type IdentityLoaderFunc func(cls PrincipalClass, name string) (CertificateProvider, error) - -type identityDriver struct { - name string - load IdentityLoaderFunc -} - -var identityDrivers []*identityDriver - var ErrUnsupportedClass = errors.New("this driver does not support loading this identity class") -func RegisterIdentityDriver(name string, load IdentityLoaderFunc) { - driver := &identityDriver{ - name: name, - load: load, - } - - identityDrivers = append(identityDrivers, driver) -} +type IdentityLoaderFunc func(cls PrincipalClass, name string) (CertificateProvider, error) func (id *substantiatedIdentity) Name() string { cert, err := id.CertificateProvider.LeafCertificate() diff --git a/mtls/provider_file.go b/mtls/provider_file.go index d5c7948..90a667a 100644 --- a/mtls/provider_file.go +++ b/mtls/provider_file.go @@ -17,10 +17,13 @@ import ( "go.fuhry.dev/runtime/mtls/certutil" "go.fuhry.dev/runtime/mtls/fsnotify" "go.fuhry.dev/runtime/utils/fsutil" + "go.fuhry.dev/runtime/utils/hashset" "go.fuhry.dev/runtime/utils/subst" ) type FileBackedCertificate struct { + obj SwappableCert + LeafPath string IntermediatesPath string PrivateKeyPath string @@ -29,12 +32,14 @@ type FileBackedCertificate struct { mu sync.Mutex tlsConfig *tls.Config - leaf *x509.Certificate - ints []*x509.Certificate - pkey crypto.PrivateKey - root *x509.Certificate + subs map[SubscriptionHandle]ReloadNotifyFunc } +var ( + _ CertificateProvider = &FileBackedCertificate{} + _ CertificateNotifyOnReload = &FileBackedCertificate{} +) + type fileBackedRoots struct { RootPath string IntermediatesPath string @@ -103,6 +108,7 @@ func LoadServiceIdentityFromKubernetesCSIDriverSPIFFE(serviceIdentity string) (* IntermediatesPath: path.Join(k8sSpiffeDir, c.leaf), PrivateKeyPath: path.Join(k8sSpiffeDir, c.key), RootPath: path.Join(k8sSpiffeDir, c.ca), + obj: NewEmptySwappableCert(), } if cert, err := fbc.LeafCertificate(); err == nil { @@ -118,6 +124,30 @@ func LoadServiceIdentityFromKubernetesCSIDriverSPIFFE(serviceIdentity string) (* return nil, fmt.Errorf("cannot find files for service identity %q in %q", serviceIdentity, k8sSpiffeDir) } +func loadCertificatesFromPEMs(paths ...string) []*x509.Certificate { + pathsSet := hashset.FromSlice(paths) + + var out []*x509.Certificate + outSet := hashset.NewHashSet[string]() + + for _, f := range pathsSet.AsSlice() { + contents, err := certutil.LoadCertificatesFromPEM(f) + if err == nil { + for _, cert := range contents { + raw := string(cert.Raw) + if outSet.Contains(raw) { + continue + } + outSet.Add(raw) + + out = append(out, cert) + } + } + } + + return out +} + func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity string) (*FileBackedCertificate, error) { certDirectory := path.Join(mtlsRootPath, serviceIdentity) @@ -138,7 +168,8 @@ func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity st logger.V(2).Errorf( "cannot load identity %q from %s: cannot read from either possible leaf "+ "certificate path:\n %s: %v\n %s: %v", - serviceIdentity, certDirectory, leafPath, leafErr, fullchainPath, err) + serviceIdentity, certDirectory, leafPath, leafErr, fullchainPath, err, + ) return nil, err } } @@ -151,7 +182,8 @@ func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity st logger.V(2).Errorf( "cannot load identity %q from %s: cannot read from either possible "+ "intermediate chain path:\n %s: %v\n %s: %v", - serviceIdentity, certDirectory, chainPath, chainErr, fullchainPath, err) + serviceIdentity, certDirectory, chainPath, chainErr, fullchainPath, err, + ) } } for _, file := range []string{keyPath, rootPath} { @@ -161,11 +193,20 @@ func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity st } } + obj := NewEmptySwappableCert() + certs := loadCertificatesFromPEMs(leafPath, chainPath, rootPath) + leaf, ints, root := certutil.SplitChainRoot(certs) + pkey, err := certutil.LoadPrivateKeyFromPEM(keyPath) + if leaf != nil && ints != nil && root != nil && pkey != nil && err == nil { + obj = NewSwappableCert(leaf, pkey, ints, root) + } + return &FileBackedCertificate{ LeafPath: leafPath, IntermediatesPath: chainPath, PrivateKeyPath: keyPath, RootPath: rootPath, + obj: obj, }, nil } @@ -193,6 +234,7 @@ func LoadUserIdentityFromFilesystem(username string) (*FileBackedCertificate, er IntermediatesPath: fullChainPath, PrivateKeyPath: keyPath, RootPath: rootPath, + obj: NewEmptySwappableCert(), } leaf, err := cert.LeafCertificate() @@ -242,6 +284,7 @@ func LoadSSLCertificateFromFilesystem(certName string) (*FileBackedCertificate, IntermediatesPath: chainPath, PrivateKeyPath: keyPath, RootPath: rootPath, + obj: NewEmptySwappableCert(), }, nil } @@ -249,8 +292,8 @@ func (c *FileBackedCertificate) LeafCertificate() (*x509.Certificate, error) { c.mu.Lock() defer c.mu.Unlock() - if c.leaf != nil { - return c.leaf, nil + if leaf := c.obj.LiveLeaf(); leaf != nil { + return leaf, nil } leaf, err := c.tryLoadLeaf() @@ -258,7 +301,7 @@ func (c *FileBackedCertificate) LeafCertificate() (*x509.Certificate, error) { return nil, err } - c.leaf = leaf + c.obj.ReplaceLeaf(leaf) return leaf, nil } @@ -268,23 +311,20 @@ func (c *FileBackedCertificate) tryLoadLeaf() (*x509.Certificate, error) { return nil, err } - for _, cert := range certs { - if cert.IsCA { - continue - } - - return cert, nil + leaf, _ := certutil.SplitChain(certs) + if leaf == nil { + return nil, fmt.Errorf("leaf certificate path %q contains no end-entity certificates", c.LeafPath) } - return nil, fmt.Errorf("leaf certificate path %q contains no end-entity certificates", c.LeafPath) + return leaf, nil } func (c *FileBackedCertificate) PrivateKey() (crypto.PrivateKey, error) { c.mu.Lock() defer c.mu.Unlock() - if c.pkey != nil { - return c.pkey, nil + if pkey := c.obj.LiveKey(); pkey != nil { + return pkey, nil } pkey, err := c.tryLoadPrivateKey() @@ -292,8 +332,8 @@ func (c *FileBackedCertificate) PrivateKey() (crypto.PrivateKey, error) { return nil, err } - c.pkey = pkey - return c.pkey, nil + c.obj.ReplaceKey(pkey) + return pkey, nil } func (c *FileBackedCertificate) tryLoadPrivateKey() (crypto.PrivateKey, error) { @@ -304,8 +344,8 @@ func (c *FileBackedCertificate) RootCertificate() (*x509.Certificate, error) { c.mu.Lock() defer c.mu.Unlock() - if c.root != nil { - return c.root, nil + if root := c.obj.LiveRoot(); root != nil { + return root, nil } root, err := c.tryLoadRoot() @@ -313,7 +353,7 @@ func (c *FileBackedCertificate) RootCertificate() (*x509.Certificate, error) { return nil, err } - c.root = root + c.obj.ReplaceRoot(root) return root, nil } @@ -336,8 +376,8 @@ func (c *FileBackedCertificate) IntermediateCertificates() ([]*x509.Certificate, c.mu.Lock() defer c.mu.Unlock() - if c.ints != nil { - return c.ints, nil + if ints := c.obj.LiveIntermediates(); ints != nil { + return ints, nil } newInts, err := c.tryLoadIntermediates() @@ -345,8 +385,8 @@ func (c *FileBackedCertificate) IntermediateCertificates() ([]*x509.Certificate, return nil, err } - c.ints = newInts - return c.ints, nil + c.obj.ReplaceIntermediates(newInts) + return newInts, nil } func (c *FileBackedCertificate) tryLoadIntermediates() ([]*x509.Certificate, error) { @@ -354,18 +394,59 @@ func (c *FileBackedCertificate) tryLoadIntermediates() ([]*x509.Certificate, err if err != nil { return nil, err } - newInts := make([]*x509.Certificate, 0) + _, newInts := certutil.SplitChain(certs) - for _, cert := range certs { - if !cert.IsCA { - continue - } + // don't check length of newInts here - sometimes there may not be any + return newInts, nil +} - newInts = append(newInts, cert) +func (c *FileBackedCertificate) Subscribe(f ReloadNotifyFunc) SubscriptionHandle { + c.mu.Lock() + defer c.mu.Unlock() + + if c.subs == nil { + c.subs = make(map[SubscriptionHandle]ReloadNotifyFunc) } - // don't check length of newInts here - sometimes there may not be any - return newInts, nil + h := NewSubscriptionHandle() + c.subs[h] = f + return h +} + +func (c *FileBackedCertificate) Unsubscribe(h SubscriptionHandle) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.subs == nil { + return + } + + delete(c.subs, h) +} + +func (c *FileBackedCertificate) notifyWatchers() { + // copy the live state + leaf, pkey, ints, root := c.obj.LiveState() + if leaf == nil || ints == nil || pkey == nil { + return + } + + // copy the notify handlers + c.mu.Lock() + if c.subs == nil { + c.mu.Unlock() + return + } + + var watchers []ReloadNotifyFunc + for _, f := range c.subs { + watchers = append(watchers, f) + } + c.mu.Unlock() + + for _, f := range watchers { + f(leaf, pkey, ints, root) + } } func (r *fileBackedRoots) init() { @@ -528,7 +609,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) { cert, err := c.tryLoadLeaf() if err == nil { logger.Infof("detected change to leaf certificate %s, reloaded", c.LeafPath) - c.leaf = cert + c.obj.ReplaceLeaf(cert) } else { logger.Warningf("leaf certificate %s changed but unable to reload: %v", c.LeafPath, err) } @@ -536,7 +617,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) { ints, err := c.tryLoadIntermediates() if err == nil { logger.Infof("detected change to intermediate certificates %s, reloaded", c.IntermediatesPath) - c.ints = ints + c.obj.ReplaceIntermediates(ints) } else { logger.Warningf("intermediate certificates %s changed but unable to reload: %v", c.IntermediatesPath, err) } @@ -544,7 +625,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) { pkey, err := c.tryLoadPrivateKey() if err == nil { logger.Infof("detected change to private key %s, reloaded", c.PrivateKeyPath) - c.pkey = pkey + c.obj.ReplaceKey(pkey) } else { logger.Warningf("private key %s changed but unable to reload: %v", c.PrivateKeyPath, err) } @@ -552,7 +633,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) { root, err := c.tryLoadRoot() if err == nil { logger.Infof("detected change to root certificate %s, reloaded", c.RootPath) - c.root = root + c.obj.ReplaceRoot(root) } else { logger.Warningf("root certificate %s changed but unable to reload: %v", c.RootPath, err) } @@ -668,25 +749,29 @@ func (f *fileProviderFactory) New(node *yaml.Node) (IdentityLoaderFunc, error) { PrivateKeyPath: key, IntermediatesPath: chain, RootPath: root, + obj: NewEmptySwappableCert(), } if err := validateIdentity(fbc); err != nil { logger.V(2).Noticef("loaded certificate failed validation: %v", err) return nil, fmt.Errorf( - "loaded certificate is not valid: %v", err) + "loaded certificate is not valid: %v", err, + ) } id := &substantiatedIdentity{fbc} if id.Class() != cls || id.Name() != princ { return nil, fmt.Errorf( "loaded identity did not match the one requested:\n requested: %s %s\n loaded: %s %s", - cls.String(), princ, id.Class().String(), id.Name()) + cls.String(), princ, id.Class().String(), id.Name(), + ) } logger.V(2).Infof( "fileProviderFactory: loaded %s identity %q:\n"+ " leaf: %s\n key: %s\n chain: %s\n root: %s", - cls.String(), princ, leaf, key, chain, root) + cls.String(), princ, leaf, key, chain, root, + ) return fbc, nil }, nil @@ -719,24 +804,6 @@ func init() { flag.StringVar(&sslCertsBaseDir, "tls.certs-dir", sslCertsBaseDir, "directory to look under for public-site SSL certificates (NOT mTLS certs)") flag.Func("mtls.certs-dir", "additional directory to search for mTLS certificates", appendMtlsCertificateDir) - RegisterIdentityDriver("file_service_global", func(cls PrincipalClass, serviceName string) (CertificateProvider, error) { - if cls != ServicePrincipal { - return nil, ErrUnsupportedClass - } - return LoadServiceIdentityFromFilesystem(serviceName) - }) - RegisterIdentityDriver("file_service_csi_spiffe", func(cls PrincipalClass, serviceName string) (CertificateProvider, error) { - if cls != ServicePrincipal { - return nil, ErrUnsupportedClass - } - return LoadServiceIdentityFromKubernetesCSIDriverSPIFFE(serviceName) - }) - RegisterIdentityDriver("file_user_home", func(cls PrincipalClass, username string) (CertificateProvider, error) { - if cls != UserPrincipal { - return nil, ErrUnsupportedClass - } - return LoadUserIdentityFromFilesystem(username) - }) RegisterRootDriver("file_etc_mtls", func() (RootsPrimitive, error) { return defaultFileBackedRoots, nil }) diff --git a/mtls/provider_interface.go b/mtls/provider_interface.go index 031909f..a66f01e 100644 --- a/mtls/provider_interface.go +++ b/mtls/provider_interface.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "errors" + "math/rand" "net" ) @@ -33,6 +34,15 @@ type CertificatePrimitive interface { NewTlsCertificate() (*tls.Certificate, error) } +type ReloadNotifyFunc func(leaf *x509.Certificate, pkey crypto.PrivateKey, ints []*x509.Certificate, root *x509.Certificate) + +type SubscriptionHandle int64 + +type CertificateNotifyOnReload interface { + Subscribe(ReloadNotifyFunc) SubscriptionHandle + Unsubscribe(SubscriptionHandle) +} + type inaccessibleCertificate struct{} var ErrCertificateInaccessible = errors.New("requested certificate is inaccessible") @@ -64,3 +74,14 @@ func (c *inaccessibleCertificate) TlsConfig(ctx context.Context) (*tls.Config, e func (c *inaccessibleCertificate) NewDialContextFunc() DialContextFunc { return MakeDialContextFunc(c) } + +func NewSubscriptionHandle() SubscriptionHandle { + return SubscriptionHandle(rand.Int63()) +} + +type certObject struct { + leaf *x509.Certificate + pkey crypto.PrivateKey + ints []*x509.Certificate + root *x509.Certificate +} diff --git a/mtls/provider_keychain_macos.go b/mtls/provider_keychain_macos.go index b794123..e527eec 100644 --- a/mtls/provider_keychain_macos.go +++ b/mtls/provider_keychain_macos.go @@ -455,8 +455,6 @@ func (f *macosKeychainProviderFactory) New(_ *yaml.Node) (IdentityLoaderFunc, er func init() { kcLogger = log.WithPrefix("mtls.macOSKeychain") - RegisterIdentityDriver("macos_keychain", NewCertificateFromMacKeychain) - RegisterProviderFactory("macos_keychain", &macosKeychainProviderFactory{}) RegisterRootDriver("macos_keychain", func() (RootsPrimitive, error) { diff --git a/mtls/provider_shared.go b/mtls/provider_shared.go index 79da91c..5213e93 100644 --- a/mtls/provider_shared.go +++ b/mtls/provider_shared.go @@ -2,10 +2,56 @@ package mtls import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" "crypto/tls" + "crypto/x509" "net" + "sync" ) +// SwappableCert wraps a leaf certificate, intermediates, root, and private key. +// +// The `Live*` methods copy the respective properties before returning them. +// +// The `Replace*` methods are concurrency-safe. Each accepts the respective new property +// and constructs a pending state from each of the four properties. If the properties +// align to build a complete chain including a leaf certificate corresponding to the +// private key, the live properties are updated. Otherwise, the replaced property is +// stored in the pending state. +type SwappableCert interface { + LiveLeaf() *x509.Certificate + LiveKey() crypto.PrivateKey + LiveIntermediates() []*x509.Certificate + LiveRoot() *x509.Certificate + + // LiveState returns an atomic snapshot of the live state. + LiveState() (*x509.Certificate, crypto.PrivateKey, []*x509.Certificate, *x509.Certificate) + + ReplaceLeaf(*x509.Certificate) + ReplaceKey(crypto.PrivateKey) + ReplaceIntermediates([]*x509.Certificate) + ReplaceRoot(*x509.Certificate) +} + +type swappableCertObject struct { + live certObject + pending certObject + mu sync.Mutex +} + +var _ SwappableCert = &swappableCertObject{} + +type hasPublic interface { + Public() crypto.PublicKey +} + +type comparablePublicKey interface { + Equal(crypto.PublicKey) bool +} + func MakeTlsCertificate(id CertificatePrimitive) (*tls.Certificate, error) { leafCertificate, err := id.LeafCertificate() if err != nil { @@ -44,3 +90,238 @@ func MakeDialContextFunc(id CertificateProvider) DialContextFunc { return dcf } + +func checkAlignment(cert *x509.Certificate, ints []*x509.Certificate, root *x509.Certificate, key crypto.PrivateKey) bool { + // ints may be nil, + if cert == nil || key == nil || root == nil { + return false + } + + privkey, ok := key.(hasPublic) + if !ok { + return false + } + + certPub, ok := cert.PublicKey.(comparablePublicKey) + if !ok { + return false + } + + if !certPub.Equal(privkey.Public()) { + return false + } + + rootPool := x509.NewCertPool() + rootPool.AddCert(root) + intsPool := x509.NewCertPool() + for _, i := range ints { + intsPool.AddCert(i) + } + vo := x509.VerifyOptions{ + Roots: rootPool, + Intermediates: intsPool, + } + + _, err := cert.Verify(vo) + return err == nil +} + +func NewEmptySwappableCert() SwappableCert { + return &swappableCertObject{} +} + +func NewSwappableCert(leaf *x509.Certificate, pkey crypto.PrivateKey, ints []*x509.Certificate, root *x509.Certificate) SwappableCert { + return &swappableCertObject{ + live: certObject{leaf, pkey, ints, root}, + } +} + +func (s *swappableCertObject) pendingLeaf() *x509.Certificate { + if s.pending.leaf != nil { + return s.pending.leaf + } + + return s.live.leaf +} + +func (s *swappableCertObject) pendingKey() crypto.PrivateKey { + if s.pending.pkey != nil { + return s.pending.pkey + } + + return s.live.pkey +} + +func (s *swappableCertObject) pendingIntermediates() []*x509.Certificate { + if s.pending.ints != nil { + return s.pending.ints + } + + return s.live.ints +} + +func (s *swappableCertObject) pendingRoot() *x509.Certificate { + if s.pending.root != nil { + return s.pending.root + } + + return s.live.root +} + +func (s *swappableCertObject) LiveLeaf() *x509.Certificate { + s.mu.Lock() + defer s.mu.Unlock() + + if s.live.leaf == nil { + return nil + } + leafCopy := *s.live.leaf + return &leafCopy +} + +func (s *swappableCertObject) LiveKey() crypto.PrivateKey { + s.mu.Lock() + defer s.mu.Unlock() + + if s.live.pkey == nil { + return nil + } + + switch k := s.live.pkey.(type) { + case *rsa.PrivateKey: + kCopy := *k + return &kCopy + case *ecdsa.PrivateKey: + kCopy := *k + return &kCopy + case *ed25519.PrivateKey: + kCopy := *k + return &kCopy + } + + // fall back to not copying... not ideal + return s.live.pkey +} + +func (s *swappableCertObject) LiveIntermediates() []*x509.Certificate { + s.mu.Lock() + defer s.mu.Unlock() + + if s.live.ints == nil { + return nil + } + + intsCopy := make([]*x509.Certificate, len(s.live.ints)) + for i, c := range s.live.ints { + cCopy := *c + intsCopy[i] = &cCopy + } + return intsCopy +} + +func (s *swappableCertObject) LiveRoot() *x509.Certificate { + s.mu.Lock() + defer s.mu.Unlock() + + if s.live.root == nil { + return nil + } + + rootCopy := *s.live.root + return &rootCopy +} + +func (s *swappableCertObject) LiveState() (leaf *x509.Certificate, pkey crypto.PrivateKey, ints []*x509.Certificate, root *x509.Certificate) { + leafCopy := *s.live.leaf + leaf = &leafCopy + + switch k := s.live.pkey.(type) { + case *rsa.PrivateKey: + kCopy := *k + pkey = kCopy + case *ecdsa.PrivateKey: + kCopy := *k + pkey = kCopy + case *ed25519.PrivateKey: + kCopy := *k + pkey = kCopy + default: + // fall back to not copying + pkey = s.live.pkey + } + + ints = make([]*x509.Certificate, len(s.live.ints)) + for i, c := range s.live.ints { + cCopy := *c + ints[i] = &cCopy + } + + rootCopy := *s.live.root + root = &rootCopy + + return +} + +func (s *swappableCertObject) ReplaceLeaf(cert *x509.Certificate) { + s.mu.Lock() + defer s.mu.Unlock() + + pkey := s.pendingKey() + ints := s.pendingIntermediates() + root := s.pendingRoot() + + if checkAlignment(cert, ints, root, pkey) { + s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey + s.pending = certObject{} + } else { + s.pending.leaf = cert + } +} + +func (s *swappableCertObject) ReplaceKey(pkey crypto.PrivateKey) { + s.mu.Lock() + defer s.mu.Unlock() + + cert := s.pendingLeaf() + ints := s.pendingIntermediates() + root := s.pendingRoot() + + if checkAlignment(cert, ints, root, pkey) { + s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey + s.pending = certObject{} + } else { + s.pending.pkey = pkey + } +} + +func (s *swappableCertObject) ReplaceIntermediates(ints []*x509.Certificate) { + s.mu.Lock() + defer s.mu.Unlock() + + cert := s.pendingLeaf() + pkey := s.pendingKey() + root := s.pendingRoot() + + if checkAlignment(cert, ints, root, pkey) { + s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey + s.pending = certObject{} + } else { + s.pending.pkey = pkey + } +} + +func (s *swappableCertObject) ReplaceRoot(root *x509.Certificate) { + s.mu.Lock() + defer s.mu.Unlock() + + cert := s.pendingLeaf() + pkey := s.pendingKey() + ints := s.pendingIntermediates() + + if checkAlignment(cert, ints, root, pkey) { + s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey + s.pending = certObject{} + } else { + s.pending.pkey = pkey + } +} diff --git a/mtls/provider_tpm2_pkcs11.go b/mtls/provider_tpm2_pkcs11.go index 4b4b1aa..813bba8 100644 --- a/mtls/provider_tpm2_pkcs11.go +++ b/mtls/provider_tpm2_pkcs11.go @@ -133,12 +133,5 @@ func (f *tpmProviderFactory) New(_ *yaml.Node) (IdentityLoaderFunc, error) { } func init() { - RegisterIdentityDriver("tpm2_pkcs11", func(cls PrincipalClass, serviceName string) (CertificateProvider, error) { - if cls != ServicePrincipal || serviceName != constants.DeviceTrustPrincipal { - return nil, ErrUnsupportedClass - } - return NewTPMBackedCertificate() - }) - RegisterProviderFactory("tpm2_pkcs11", &tpmProviderFactory{}) } diff --git a/mtls/verify_names.go b/mtls/verify_names.go index c926d39..bd81296 100644 --- a/mtls/verify_names.go +++ b/mtls/verify_names.go @@ -3,11 +3,16 @@ package mtls import ( "crypto/tls" "crypto/x509" + "errors" "fmt" + "net" "net/url" "regexp" "strings" + "gopkg.in/yaml.v3" + + "go.fuhry.dev/runtime/mtls/certutil" "go.fuhry.dev/runtime/utils/hashset" "go.fuhry.dev/runtime/utils/log" ) @@ -20,6 +25,16 @@ type RemoteIdentity struct { Principal string } +type verifyYamlRule struct { + User string `yaml:"user"` + Service string `yaml:"service"` + Domain string `yaml:"domain"` +} + +type VerifyYamlConfig struct { + AllowedPrincipals []*verifyYamlRule `yaml:"allowed_principals"` +} + const ( Domain VerifyMatchBy = iota Service @@ -31,6 +46,9 @@ const ( exprSpiffeServiceIdentity = `^spiffe://(?P[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)/service/(?P[a-z0-9_-]+)$` exprSpiffeUserIdentity = `^spiffe://(?P[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)/user/(?P[a-z0-9_-]+)$` exprMtlsInternalIdenitty = `^(?P[A-Za-z0-9_-]+)\.(?P[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)\.mtls\.internal$` + exprDomain = `^\.?([a-z0-9-]+)(\.[a-z0-9-]+)*$` + exprUserOrService = `^[a-z0-9_-]+$` + tokenWildcard = `*` ) func (c VerifyMatchBy) String() string { @@ -48,20 +66,30 @@ func (c VerifyMatchBy) String() string { panic("invalid value for IdentityClass") } -var spiffeServiceIdentity, spiffeUserIdentity, mtlsInternalIdentity *regexp.Regexp +var spiffeServiceIdentity, + spiffeUserIdentity, + mtlsInternalIdentity, + aclDomainExpr, + aclUserOrServiceExpr *regexp.Regexp func init() { spiffeServiceIdentity = regexp.MustCompile(exprSpiffeServiceIdentity) spiffeUserIdentity = regexp.MustCompile(exprSpiffeUserIdentity) mtlsInternalIdentity = regexp.MustCompile(exprMtlsInternalIdenitty) + aclDomainExpr = regexp.MustCompile(exprDomain) + aclUserOrServiceExpr = regexp.MustCompile(exprUserOrService) } // MTLSPeerVerifier validates SPIFFE CNs and SANs of peer certificates, and facilitates // configuration of conventional certificate validation (chain, notbefore/notafter, key // usage, etc.) against the mTLS CA. // -// It will not configure the local identity's certificates, private keys, etc. - for that, -// use `Identity.TlsConfig()`. +// Identities may be verified at the time of connection using ConfigureClient/ConfigureServer, or +// later by setting `AllowFrom(All)` and creating a separate verifier for checking names using +// application-layer information (web route, etc.). +// +// MTLSPeerVerifier does not configure the local identity's certificates, private keys, etc. - for +// that, use `Identity.TlsConfig()`. type MTLSPeerVerifier interface { // ConfigureServer modifies a tls.Config struct to require client certificates, and verify // the client certificate using the custom chain and name verifiers. @@ -83,15 +111,21 @@ type MTLSPeerVerifier interface { // // This function is called after VerifyPeerCertificate completes without error. VerifyPeerCert(*x509.Certificate, x509.VerifyOptions) error + + // GetPeerIdentity attempts to extract the peer identity from a tls.Conn. + GetPeerIdentity(net.Conn) (*RemoteIdentity, error) + + // MatchRemoteIdentity returns nil if the provided RemoteIdentity is allow-listed. + MatchRemoteIdentity(*RemoteIdentity) error } type mtlsPeerVerifier struct { - MTLSPeerVerifier - allowedPrincipals map[VerifyMatchBy]*hashset.HashSet[string] log log.Logger } +var _ MTLSPeerVerifier = &mtlsPeerVerifier{} + func NewPeerNameVerifier() MTLSPeerVerifier { cv := &mtlsPeerVerifier{ allowedPrincipals: make(map[VerifyMatchBy]*hashset.HashSet[string]), @@ -142,13 +176,23 @@ func (cv *mtlsPeerVerifier) verifyConnectionFunc(verifyOpts x509.VerifyOptions, return ErrNoCertificatePresented } - peerCert := cs.PeerCertificates[0] + peerCert, intermediates := certutil.SplitChain(cs.PeerCertificates) + verifyOptsCopy := verifyOpts + intermediatesPool := verifyOpts.Intermediates.Clone() + for _, i := range intermediates { + intermediatesPool.AddCert(i) + } + verifyOptsCopy.Intermediates = intermediatesPool - return cv.VerifyPeerCert(peerCert, verifyOpts) + return cv.VerifyPeerCert(peerCert, verifyOptsCopy) } } func (cv *mtlsPeerVerifier) VerifyPeerCert(peerCert *x509.Certificate, verifyOpts x509.VerifyOptions) error { + if peerCert == nil { + return ErrNoCertificatePresented + } + _, err := peerCert.Verify(verifyOpts) if err != nil { return err @@ -196,50 +240,95 @@ func (cv *mtlsPeerVerifier) AllowFrom(class VerifyMatchBy, principals ...string) } } -func (cv *mtlsPeerVerifier) checkName(name string) error { - id, err := ParseRemoteIdentity(name) - if err != nil { - return err - } - cv.log.V(3).Debugf("parsed name %q to %+v (class=%s)", name, id, id.Class.String()) - +func (cv *mtlsPeerVerifier) MatchRemoteIdentity(rid *RemoteIdentity) error { if _, ok := cv.allowedPrincipals[All]; ok { return nil } if allowedDomains, ok := cv.allowedPrincipals[Domain]; ok { - if allowedDomains.Contains(id.Domain) { - cv.log.V(3).Debugf("domain %q exactly matched allowlist", id.Domain) - return nil - } domainOk := false - for _, val := range allowedDomains.AsSlice() { - if val[0] != '.' { - continue - } - if strings.HasSuffix(id.Domain, val) { - cv.log.V(3).Debugf("domain %q matched suffix rule %q", id.Domain, val) - domainOk = true - break + if allowedDomains.Contains("*") { + cv.log.V(3).Debug("list contains '*' entry; all domains allowed") + domainOk = true + } else if allowedDomains.Contains(rid.Domain) { + cv.log.V(3).Debugf("domain %q exactly matched allowlist", rid.Domain) + domainOk = true + } else { + for _, val := range allowedDomains.AsSlice() { + if val[0] == '.' && strings.HasSuffix(rid.Domain, val) { + cv.log.V(3).Debugf("domain %q matched suffix rule %q", rid.Domain, val) + domainOk = true + break + } } } if !domainOk { - return fmt.Errorf("trust domain %q is not allowed to authenticate to this service", id.Domain) + return fmt.Errorf("trust domain %q is not allowed to authenticate to this service", rid.Domain) } } - if allowedPrincipals, ok := cv.allowedPrincipals[id.Class]; ok { - if !allowedPrincipals.Contains(id.Principal) { - return fmt.Errorf("principal %q is not allowed to authenticate to this service", id.Principal) + if allowedPrincipals, ok := cv.allowedPrincipals[rid.Class]; ok { + if allowedPrincipals.Contains("*") { + cv.log.V(3).Debugf("list contains '*' entry; all principals of type %s are allowed", rid.Class.String()) + } + + if !allowedPrincipals.Contains(rid.Principal) { + return fmt.Errorf("principal %q is not allowed to authenticate to this service", rid.Principal) } - cv.log.V(3).Debugf("principal %q matched allowlist", id.Principal) + cv.log.V(3).Debugf("principal %q matched allowlist", rid.Principal) return nil } return fmt.Errorf("principals of this type are not allowed") } +func (cv *mtlsPeerVerifier) checkName(name string) error { + id, err := ParseRemoteIdentity(name) + if err != nil { + return err + } + cv.log.V(3).Debugf("parsed name %q to %+v (class=%s)", name, id, id.Class.String()) + + return cv.MatchRemoteIdentity(id) +} + +func (cv *mtlsPeerVerifier) GetPeerIdentity(conn net.Conn) (*RemoteIdentity, error) { + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return nil, fmt.Errorf("conn is %T, not *tls.Conn", conn) + } + + cs := tlsConn.ConnectionState() + if len(cs.PeerCertificates) == 0 { + return nil, errors.New("no peer certificate provided") + } + + leaf, _ := certutil.SplitChain(cs.PeerCertificates) + + var lastErr error + if ri, err := ParseRemoteIdentity(leaf.Subject.CommonName); err == nil { + logger.V(2).Debugf("found peer identity in CN: %s", ri.ToSpiffe().String()) + return ri, nil + } else { + lastErr = err + } + + for i, url := range leaf.URIs { + if ri, err := ParseRemoteIdentity(url.String()); err == nil { + logger.V(2).Debugf("found peer identity in URI SAN %d: %s", i, ri.ToSpiffe().String()) + return ri, nil + } else { + lastErr = err + } + } + + if lastErr == nil { + lastErr = errors.New("found no recognizable SPIFFE URLs in peer certificate") + } + return nil, lastErr +} + func ParseRemoteIdentity(name string) (*RemoteIdentity, error) { exps := []struct { expr *regexp.Regexp @@ -302,3 +391,66 @@ func (i *RemoteIdentity) ToDnsName() string { i.Domain) } } + +func NewVerifyYamlConfig(node *yaml.Node) (*VerifyYamlConfig, error) { + out := &VerifyYamlConfig{} + if err := node.Decode(out); err != nil { + return nil, err + } + + if len(out.AllowedPrincipals) == 0 { + return nil, errors.New("found no allowed_principals") + } + + for i, ent := range out.AllowedPrincipals { + if err := ent.validate(); err != nil { + return nil, fmt.Errorf("rule %d: %v", i, err) + } + } + + return out, nil +} + +func (v *VerifyYamlConfig) Apply(verifier MTLSPeerVerifier) error { + for i, rule := range v.AllowedPrincipals { + if err := rule.validate(); err != nil { + return fmt.Errorf("rule %d: %v", i, err) + } + + if rule.Domain != "" { + verifier.AllowFrom(Domain, rule.Domain) + } else if rule.User != "" { + verifier.AllowFrom(User, rule.User) + } else if rule.Service != "" { + verifier.AllowFrom(Service, rule.Service) + } + } + return nil +} + +func (r *verifyYamlRule) validate() error { + var count uint + if r.Domain != "" { + if !aclDomainExpr.MatchString(r.Domain) { + return fmt.Errorf("domain expression is invalid: %q", r.Domain) + } + count++ + } + if r.User != "" { + if r.User != "*" && !aclUserOrServiceExpr.MatchString(r.User) { + return fmt.Errorf("user expression is invalid: %q", r.User) + } + count++ + } + if r.Service != "" { + if r.Service != "*" && !aclUserOrServiceExpr.MatchString(r.Service) { + return fmt.Errorf("service expression is invalid: %q", r.Service) + } + count++ + } + if count != 1 { + return fmt.Errorf("each rule must specify exactly one of domain, user or service") + } + + return nil +}