diff --git a/server/auth.go b/server/auth.go index 1d7b45e4..f9bdb4ae 100644 --- a/server/auth.go +++ b/server/auth.go @@ -14,10 +14,12 @@ package server import ( + "crypto/sha256" "crypto/tls" "crypto/x509/pkix" "encoding/asn1" "encoding/base64" + "encoding/hex" "fmt" "net" "net/url" @@ -185,6 +187,19 @@ func (s *Server) checkAuthforWarnings() { // Warning about using plaintext passwords. s.Warnf("Plaintext passwords detected, use nkeys or bcrypt") } + // only warn about client connections others use bidirectional TLS + if len(s.opts.TLSPinnedCerts) > 0 && !(s.opts.TLSVerify || s.opts.TLSMap) { + s.Warnf("Pinned Certs specified but no verify or verify_and_map that would require presenting a client cert") + } + if len(s.opts.Websocket.TLSPinnedCerts) > 0 && !(s.opts.Websocket.TLSMap) { + s.Warnf("Websocket Pinned Certs specified but no verify_and_map that would require presenting a client cert") + } + if len(s.opts.MQTT.TLSPinnedCerts) > 0 && !(s.opts.MQTT.TLSMap) { + s.Warnf("MQTT Pinned Certs specified but no verify_and_map that would require presenting a client cert") + } + if len(s.opts.LeafNode.TLSPinnedCerts) > 0 && !(s.opts.LeafNode.TLSMap) { + s.Warnf("Leaf Pinned Certs specified but verify_and_map that would require presenting a client cert") + } } // If Users or Nkeys options have definitions without an account defined, @@ -344,6 +359,25 @@ func (s *Server) isClientAuthorized(c *client) bool { return true } +// returns false if the client needs to be disconnected +func (s *Server) matchesPinnedCert(c *client, tlsPinnedCerts PinnedCertSet) bool { + if tlsPinnedCerts == nil { + return true + } + tlsState := c.GetTLSConnectionState() + if tlsState == nil || len(tlsState.PeerCertificates) == 0 || tlsState.PeerCertificates[0] == nil { + c.Debugf("Failed pinned cert test as client did not provide a certificate") + return false + } + sha := sha256.Sum256(tlsState.PeerCertificates[0].RawSubjectPublicKeyInfo) + keyId := hex.EncodeToString(sha[:]) + if _, ok := tlsPinnedCerts[keyId]; !ok { + c.Debugf("Failed pinned cert test for key id: %s", keyId) + return false + } + return true +} + func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) bool { var ( nkey *NkeyUser @@ -366,11 +400,6 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo authRequired = s.websocket.authOverride } } - if !authRequired { - // TODO(dlc) - If they send us credentials should we fail? - s.mu.Unlock() - return true - } var ( username string password string @@ -378,12 +407,14 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo noAuthUser string ) tlsMap := opts.TLSMap + tlsPinnedCerts := opts.TLSPinnedCerts if c.kind == CLIENT { switch c.clientType() { case MQTT: mo := &opts.MQTT // Always override TLSMap. tlsMap = mo.TLSMap + tlsPinnedCerts = mo.TLSPinnedCerts // The rest depends on if there was any auth override in // the mqtt's config. if s.mqtt.authOverride { @@ -397,6 +428,7 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo wo := &opts.Websocket // Always override TLSMap. tlsMap = wo.TLSMap + tlsPinnedCerts = wo.TLSPinnedCerts // The rest depends on if there was any auth override in // the websocket's config. if s.websocket.authOverride { @@ -409,7 +441,18 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo } } else { tlsMap = opts.LeafNode.TLSMap + tlsPinnedCerts = opts.LeafNode.TLSPinnedCerts } + if !s.matchesPinnedCert(c, tlsPinnedCerts) { + s.mu.Unlock() + return false + } + if !authRequired { + // TODO(dlc) - If they send us credentials should we fail? + s.mu.Unlock() + return true + } + if !ao { noAuthUser = opts.NoAuthUser username = opts.Username @@ -872,6 +915,10 @@ func (s *Server) isRouterAuthorized(c *client) bool { return s.opts.CustomRouterAuthentication.Check(c) } + if !s.matchesPinnedCert(c, opts.Cluster.TLSPinnedCerts) { + return false + } + if opts.Cluster.TLSMap || opts.Cluster.TLSCheckKnownURLs { return checkClientTLSCertSubject(c, func(user string, _ *ldap.DN, isDNSAltName bool) (string, bool) { if user == "" { @@ -907,6 +954,10 @@ func (s *Server) isGatewayAuthorized(c *client) bool { // Snapshot server options. opts := s.getOpts() + if !s.matchesPinnedCert(c, opts.Gateway.TLSPinnedCerts) { + return false + } + // Check whether TLS map is enabled, otherwise use single user/pass. if opts.Gateway.TLSMap || opts.Gateway.TLSCheckKnownURLs { return checkClientTLSCertSubject(c, func(user string, _ *ldap.DN, isDNSAltName bool) (string, bool) { @@ -969,6 +1020,10 @@ func (s *Server) isLeafNodeAuthorized(c *client) bool { return s.registerLeafWithAccount(c, account) } + if !s.matchesPinnedCert(c, opts.LeafNode.TLSPinnedCerts) { + return false + } + // If leafnodes config has an authorization{} stanza, this takes precedence. // The user in CONNECT must match. We will bind to the account associated // with that user (from the leafnode's authorization{} config). @@ -1050,6 +1105,9 @@ func comparePasswords(serverPassword, clientPassword string) bool { } func validateAuth(o *Options) error { + if err := validatePinnedCerts(o.TLSPinnedCerts); err != nil { + return err + } for _, u := range o.Users { if err := validateAllowedConnectionTypes(u.AllowedConnectionTypes); err != nil { return err diff --git a/server/gateway.go b/server/gateway.go index d9ed2889..b5662267 100644 --- a/server/gateway.go +++ b/server/gateway.go @@ -296,6 +296,9 @@ func validateGatewayOptions(o *Options) error { return fmt.Errorf("gateway %q has no URL", g.Name) } } + if err := validatePinnedCerts(o.Gateway.TLSPinnedCerts); err != nil { + return fmt.Errorf("gateway %q: %v", o.Gateway.Name, err) + } return nil } diff --git a/server/leafnode.go b/server/leafnode.go index 6404381f..be88167c 100644 --- a/server/leafnode.go +++ b/server/leafnode.go @@ -308,6 +308,9 @@ func validateLeafNode(o *Options) error { if o.SystemAccount == "" { return fmt.Errorf("leaf nodes and gateways (both being defined) require a system account to also be configured") } + if err := validatePinnedCerts(o.LeafNode.TLSPinnedCerts); err != nil { + return fmt.Errorf("leafnode: %v", err) + } return nil } diff --git a/server/mqtt.go b/server/mqtt.go index 5ed0a72e..eb147b62 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -554,6 +554,9 @@ func validateMQTTOptions(o *Options) error { o.LeafNode.Port == 0 && len(o.LeafNode.Remotes) == 0 { return errMQTTStandaloneNeedsJetStream } + if err := validatePinnedCerts(mo.TLSPinnedCerts); err != nil { + return fmt.Errorf("mqtt: %v", err) + } return nil } diff --git a/server/opts.go b/server/opts.go index 639d0c74..dedb8be4 100644 --- a/server/opts.go +++ b/server/opts.go @@ -53,6 +53,9 @@ func NoErrOnUnknownFields(noError bool) { atomic.StoreInt32(&allowUnknownTopLevelField, val) } +// Set of lower case hex-encoded sha256 of DER encoded SubjectPublicKeyInfo +type PinnedCertSet map[string]struct{} + // ClusterOpts are options for clusters. // NOTE: This structure is no longer used for monitoring endpoints // and json tags are deprecated and may be removed in the future. @@ -68,6 +71,7 @@ type ClusterOpts struct { TLSConfig *tls.Config `json:"-"` TLSMap bool `json:"-"` TLSCheckKnownURLs bool `json:"-"` + TLSPinnedCerts PinnedCertSet `json:"-"` ListenStr string `json:"-"` Advertise string `json:"-"` NoAdvertise bool `json:"-"` @@ -91,6 +95,7 @@ type GatewayOpts struct { TLSTimeout float64 `json:"tls_timeout,omitempty"` TLSMap bool `json:"-"` TLSCheckKnownURLs bool `json:"-"` + TLSPinnedCerts PinnedCertSet `json:"-"` Advertise string `json:"advertise,omitempty"` ConnectRetries int `json:"connect_retries,omitempty"` Gateways []*RemoteGatewayOpts `json:"gateways,omitempty"` @@ -123,6 +128,7 @@ type LeafNodeOpts struct { TLSConfig *tls.Config `json:"-"` TLSTimeout float64 `json:"tls_timeout,omitempty"` TLSMap bool `json:"-"` + TLSPinnedCerts PinnedCertSet `json:"-"` Advertise string `json:"-"` NoAdvertise bool `json:"-"` ReconnectInterval time.Duration `json:"-"` @@ -226,6 +232,7 @@ type Options struct { TLSKey string `json:"-"` TLSCaCert string `json:"-"` TLSConfig *tls.Config `json:"-"` + TLSPinnedCerts PinnedCertSet `json:"-"` AllowNonTLS bool `json:"-"` WriteDeadline time.Duration `json:"-"` MaxClosedClients int `json:"-"` @@ -316,6 +323,9 @@ type WebsocketOpts struct { // If true, map certificate values for authentication purposes. TLSMap bool + // When present, accepted client certificates (verify/verify_and_map) must be in this list + TLSPinnedCerts PinnedCertSet + // If true, the Origin header must match the request's host. SameOrigin bool @@ -361,6 +371,8 @@ type MQTTOpts struct { TLSMap bool // Timeout for the TLS handshake TLSTimeout float64 + // Set of allowable certificates + TLSPinnedCerts PinnedCertSet // AckWait is the amount of time after which a QoS 1 message sent to // a client is redelivered as a DUPLICATE if the server has not @@ -470,6 +482,7 @@ type TLSConfigOpts struct { Timeout float64 Ciphers []uint16 CurvePreferences []tls.CurveID + PinnedCerts PinnedCertSet } var tlsUsage = ` @@ -827,7 +840,7 @@ func (o *Options) processConfigFileLine(k string, v interface{}, errors *[]error } o.TLSTimeout = tc.Timeout o.TLSMap = tc.Map - + o.TLSPinnedCerts = tc.PinnedCerts case "allow_non_tls": o.AllowNonTLS = v.(bool) case "write_deadline": @@ -1272,6 +1285,7 @@ func parseCluster(v interface{}, opts *Options, errors *[]error, warnings *[]err opts.Cluster.TLSConfig = config opts.Cluster.TLSTimeout = tlsopts.Timeout opts.Cluster.TLSMap = tlsopts.Map + opts.Cluster.TLSPinnedCerts = tlsopts.PinnedCerts opts.Cluster.TLSCheckKnownURLs = tlsopts.TLSCheckKnownURLs case "cluster_advertise", "advertise": opts.Cluster.Advertise = mv.(string) @@ -1389,6 +1403,7 @@ func parseGateway(v interface{}, o *Options, errors *[]error, warnings *[]error) o.Gateway.TLSTimeout = tlsopts.Timeout o.Gateway.TLSMap = tlsopts.Map o.Gateway.TLSCheckKnownURLs = tlsopts.TLSCheckKnownURLs + o.Gateway.TLSPinnedCerts = tlsopts.PinnedCerts case "advertise": o.Gateway.Advertise = mv.(string) case "connect_retries": @@ -1612,6 +1627,7 @@ func parseLeafNodes(v interface{}, opts *Options, errors *[]error, warnings *[]e } opts.LeafNode.TLSTimeout = tc.Timeout opts.LeafNode.TLSMap = tc.Map + opts.LeafNode.TLSPinnedCerts = tc.PinnedCerts case "leafnode_advertise", "advertise": opts.LeafNode.Advertise = mv.(string) case "no_advertise": @@ -3444,6 +3460,24 @@ func parseTLS(v interface{}, isClientCtx bool) (t *TLSConfigOpts, retErr error) at = mv } tc.Timeout = at + case "pinned_certs": + ra, ok := mv.([]interface{}) + if !ok { + return nil, &configErr{tk, "error parsing tls config, expected 'pinned_certs' to be a list of hex-encoded sha256 of DER encoded SubjectPublicKeyInfo"} + } + if len(ra) != 0 { + wl := PinnedCertSet{} + re := regexp.MustCompile("^[A-Fa-f0-9]{64}$") + for _, r := range ra { + tk, r := unwrapValue(r, <) + entry := strings.ToLower(r.(string)) + if !re.MatchString(entry) { + return nil, &configErr{tk, fmt.Sprintf("error parsing tls config, 'pinned_certs' key %s does not look like hex-encoded sha256 of DER encoded SubjectPublicKeyInfo", entry)} + } + wl[entry] = struct{}{} + } + tc.PinnedCerts = wl + } default: return nil, &configErr{tk, fmt.Sprintf("error parsing tls config, unknown field [%q]", mk)} } @@ -3573,6 +3607,7 @@ func parseWebsocket(v interface{}, o *Options, errors *[]error, warnings *[]erro continue } o.Websocket.TLSMap = tc.Map + o.Websocket.TLSPinnedCerts = tc.PinnedCerts case "same_origin": o.Websocket.SameOrigin = mv.(bool) case "allowed_origins", "allowed_origin", "allow_origins", "allow_origin", "origins", "origin": @@ -3662,6 +3697,7 @@ func parseMQTT(v interface{}, o *Options, errors *[]error, warnings *[]error) er } o.MQTT.TLSTimeout = tc.Timeout o.MQTT.TLSMap = tc.Map + o.MQTT.TLSPinnedCerts = tc.PinnedCerts case "authorization", "authentication": auth := parseSimpleAuth(tk, errors, warnings) o.MQTT.Username = auth.user diff --git a/server/opts_test.go b/server/opts_test.go index 3714c21f..09cbd0c6 100644 --- a/server/opts_test.go +++ b/server/opts_test.go @@ -890,6 +890,94 @@ func TestNkeyUsersConfig(t *testing.T) { } } +// Test pinned certificates +func TestTlsPinnedCertificates(t *testing.T) { + confFileName := createConfFile(t, []byte(` + tls { + cert_file: "./configs/certs/server.pem" + key_file: "./configs/certs/key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"] + } + cluster { + port -1 + name cluster-hub + tls { + cert_file: "./configs/certs/server.pem" + key_file: "./configs/certs/key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"] + } + } + leafnodes { + port -1 + tls { + cert_file: "./configs/certs/server.pem" + key_file: "./configs/certs/key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"] + } + } + gateway { + name: "A" + port -1 + tls { + cert_file: "./configs/certs/server.pem" + key_file: "./configs/certs/key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"] + } + } + websocket { + port -1 + tls { + cert_file: "./configs/certs/server.pem" + key_file: "./configs/certs/key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"] + } + } + mqtt { + port -1 + tls { + cert_file: "./configs/certs/server.pem" + key_file: "./configs/certs/key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"] + } + }`)) + defer removeFile(t, confFileName) + opts, err := ProcessConfigFile(confFileName) + if err != nil { + t.Fatalf("Received an error reading config file: %v", err) + } + check := func(set PinnedCertSet) { + t.Helper() + if l := len(set); l != 2 { + t.Fatalf("Expected 2 pinned certificates, got got %d", l) + } + } + + check(opts.TLSPinnedCerts) + check(opts.LeafNode.TLSPinnedCerts) + check(opts.Cluster.TLSPinnedCerts) + check(opts.MQTT.TLSPinnedCerts) + check(opts.Gateway.TLSPinnedCerts) + check(opts.Websocket.TLSPinnedCerts) +} + func TestNkeyUsersDefaultPermissionsConfig(t *testing.T) { confFileName := createConfFile(t, []byte(` authorization { diff --git a/server/reload.go b/server/reload.go index 189dfa03..424cb4f1 100644 --- a/server/reload.go +++ b/server/reload.go @@ -215,6 +215,17 @@ func (t *tlsTimeoutOption) Apply(server *Server) { server.Noticef("Reloaded: tls timeout = %v", t.newValue) } +// tlsPinnedCertOption implements the option interface for the tls `pinned_certs` setting. +type tlsPinnedCertOption struct { + noopOption + newValue PinnedCertSet +} + +// Apply is a no-op because the pinned certs will be reloaded after options are applied. +func (t *tlsPinnedCertOption) Apply(server *Server) { + server.Noticef("Reloaded: %d pinned_certs", len(t.newValue)) +} + // authOption is a base struct that provides default option behaviors. type authOption struct { noopOption @@ -790,7 +801,7 @@ func imposeOrder(value interface{}) error { }) case WebsocketOpts: sort.Strings(value.AllowedOrigins) - case string, bool, int, int32, int64, time.Duration, float64, nil, LeafNodeOpts, ClusterOpts, *tls.Config, + case string, bool, int, int32, int64, time.Duration, float64, nil, LeafNodeOpts, ClusterOpts, *tls.Config, PinnedCertSet, *URLAccResolver, *MemAccResolver, *DirAccResolver, *CacheDirAccResolver, Authentication, MQTTOpts, jwt.TagList: // explicitly skipped types default: @@ -864,6 +875,8 @@ func (s *Server) diffOptions(newOpts *Options) ([]option, error) { diffOpts = append(diffOpts, &tlsOption{newValue: newValue.(*tls.Config)}) case "tlstimeout": diffOpts = append(diffOpts, &tlsTimeoutOption{newValue: newValue.(float64)}) + case "tlspinnedcerts": + diffOpts = append(diffOpts, &tlsPinnedCertOption{newValue: newValue.(PinnedCertSet)}) case "username": diffOpts = append(diffOpts, &usernameOption{}) case "password": diff --git a/server/server.go b/server/server.go index 0214efcf..6fde16c0 100644 --- a/server/server.go +++ b/server/server.go @@ -26,6 +26,7 @@ import ( "math/rand" "net" "net/http" + "regexp" // Allow dynamic profiling. _ "net/http/pprof" @@ -549,7 +550,10 @@ func (s *Server) ClientURL() string { return fmt.Sprintf("%s%s:%d", scheme, opts.Host, opts.Port) } -func validateClusterName(o *Options) error { +func validateCluster(o *Options) error { + if err := validatePinnedCerts(o.Cluster.TLSPinnedCerts); err != nil { + return fmt.Errorf("cluster: %v", err) + } // Check that cluster name if defined matches any gateway name. if o.Gateway.Name != "" && o.Gateway.Name != o.Cluster.Name { if o.Cluster.Name != "" { @@ -561,6 +565,17 @@ func validateClusterName(o *Options) error { return nil } +func validatePinnedCerts(pinned PinnedCertSet) error { + re := regexp.MustCompile("^[a-f0-9]{64}$") + for certId := range pinned { + entry := strings.ToLower(certId) + if !re.MatchString(entry) { + return fmt.Errorf("error parsing 'pinned_certs' key %s does not look like lower case hex-encoded sha256 of DER encoded SubjectPublicKeyInfo", entry) + } + } + return nil +} + func validateOptions(o *Options) error { if o.LameDuckDuration > 0 && o.LameDuckGracePeriod >= o.LameDuckDuration { return fmt.Errorf("lame duck grace period (%v) should be strictly lower than lame duck duration (%v)", @@ -585,7 +600,7 @@ func validateOptions(o *Options) error { return err } // Check that cluster name if defined matches any gateway name. - if err := validateClusterName(o); err != nil { + if err := validateCluster(o); err != nil { return err } if err := validateMQTTOptions(o); err != nil { diff --git a/server/websocket.go b/server/websocket.go index 940ac627..b699631d 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -886,6 +886,9 @@ func validateWebsocketOptions(o *Options) error { return fmt.Errorf("trusted operators or trusted keys configuration is required for JWT authentication via cookie %q", wo.JWTCookie) } } + if err := validatePinnedCerts(wo.TLSPinnedCerts); err != nil { + return fmt.Errorf("websocket: %v", err) + } return nil } diff --git a/test/tls_test.go b/test/tls_test.go index 87c77dbf..dce01490 100644 --- a/test/tls_test.go +++ b/test/tls_test.go @@ -1935,3 +1935,48 @@ func TestTLSClientSVIDAuth(t *testing.T) { }) } } + +func TestTLSPinnedCerts(t *testing.T) { + tmpl := ` + host: localhost + port: -1 + tls { + ca_file: "configs/certs/ca.pem" + cert_file: "configs/certs/server-cert.pem" + key_file: "configs/certs/server-key.pem" + # Require a client certificate and map user id from certificate + verify: true + pinned_certs: ["%s"] + }` + + confFileName := createConfFile(t, []byte(fmt.Sprintf(tmpl, "aaaaaaaa09fde09451411ba3b42c0f74727d61a974c69fd3cf5257f39c75f0e9"))) + defer removeFile(t, confFileName) + srv, o := RunServerWithConfig(confFileName) + defer srv.Shutdown() + + if len(o.TLSPinnedCerts) != 1 { + t.Fatal("expected one pinned cert") + } + + opts := []nats.Option{ + nats.RootCAs("configs/certs/ca.pem"), + nats.ClientCert("./configs/certs/client-cert.pem", "./configs/certs/client-key.pem"), + } + + nc, err := nats.Connect(srv.ClientURL(), opts...) + if err == nil { + nc.Close() + t.Fatalf("Expected error trying to connect without a certificate in pinned_certs") + } + + ioutil.WriteFile(confFileName, []byte(fmt.Sprintf(tmpl, "bf6f821f09fde09451411ba3b42c0f74727d61a974c69fd3cf5257f39c75f0e9")), 0660) + if err := srv.Reload(); err != nil { + t.Fatalf("on Reload got %v", err) + } + // reload pinned to the certs used + nc, err = nats.Connect(srv.ClientURL(), opts...) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + nc.Close() +}