From f7932e235e8266b9078727829869e515a3292752 Mon Sep 17 00:00:00 2001 From: "R.I.Pienaar" Date: Fri, 11 Sep 2020 12:46:43 +0200 Subject: [PATCH] (#987) anon tls requires JWTs This arranges things that Anonymous TLS brokers require a JWT passed during the connection, this is the same JWT that the AAA Signer signs. It then verifies the JWT using the public certificate that signed it, checks validity etc and extracts the callerid This then create allow rules allowing subscribe only to *.reply.md5(caller).> ensuring that a user with a JWT can only subscribe to reply subjects for his own user. The connector and Message is modified to publish to this same reply subject pattern. This effectively works around the lack of NATS private reply subjects and prevent random clients connecting to an anonymous TLS server from reading replies or requests. Signed-off-by: R.I.Pienaar --- CONFIGURATION.md | 53 ++++---- broker/network/ipauth.go | 135 ++++++++++++++++++-- broker/network/ipauth_test.go | 162 ++++++++++++++++++++++++ broker/network/network.go | 18 +-- broker/network/network_test.go | 6 +- choria/connection.go | 32 ++++- choria/framework.go | 66 ++++++++++ choria/framework_test.go | 157 ++++++++++++++++++++++- choria/message.go | 23 ++-- config/choria.go | 1 + config/docstrings.go | 3 +- go.mod | 3 +- go.sum | 4 + providers/agent/mcorpc/client/client.go | 2 +- 14 files changed, 600 insertions(+), 65 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 98fd2cbac..d248d442e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -59,29 +59,30 @@ A few special types are defined, the rest map to standard Go types |[plugin.choria.require_client_filter](#pluginchoriarequire_client_filter)|[plugin.choria.security.certname_whitelist](#pluginchoriasecuritycertname_whitelist)| |[plugin.choria.security.privileged_users](#pluginchoriasecurityprivileged_users)|[plugin.choria.security.request_signer.token_environment](#pluginchoriasecurityrequest_signertoken_environment)| |[plugin.choria.security.request_signer.token_file](#pluginchoriasecurityrequest_signertoken_file)|[plugin.choria.security.request_signer.url](#pluginchoriasecurityrequest_signerurl)| -|[plugin.choria.security.serializer](#pluginchoriasecurityserializer)|[plugin.choria.server.provision](#pluginchoriaserverprovision)| -|[plugin.choria.srv_domain](#pluginchoriasrv_domain)|[plugin.choria.ssldir](#pluginchoriassldir)| -|[plugin.choria.stats_address](#pluginchoriastats_address)|[plugin.choria.stats_port](#pluginchoriastats_port)| -|[plugin.choria.status_file_path](#pluginchoriastatus_file_path)|[plugin.choria.status_update_interval](#pluginchoriastatus_update_interval)| -|[plugin.choria.use_srv](#pluginchoriause_srv)|[plugin.nats.credentials](#pluginnatscredentials)| -|[plugin.nats.ngs](#pluginnatsngs)|[plugin.nats.pass](#pluginnatspass)| -|[plugin.nats.user](#pluginnatsuser)|[plugin.scout.overrides](#pluginscoutoverrides)| -|[plugin.scout.tags](#pluginscouttags)|[plugin.security.always_overwrite_cache](#pluginsecurityalways_overwrite_cache)| -|[plugin.security.certmanager.alt_names](#pluginsecuritycertmanageralt_names)|[plugin.security.certmanager.issuer](#pluginsecuritycertmanagerissuer)| -|[plugin.security.certmanager.namespace](#pluginsecuritycertmanagernamespace)|[plugin.security.certmanager.replace](#pluginsecuritycertmanagerreplace)| -|[plugin.security.cipher_suites](#pluginsecuritycipher_suites)|[plugin.security.client_anon_tls](#pluginsecurityclient_anon_tls)| -|[plugin.security.ecc_curves](#pluginsecurityecc_curves)|[plugin.security.file.ca](#pluginsecurityfileca)| -|[plugin.security.file.cache](#pluginsecurityfilecache)|[plugin.security.file.certificate](#pluginsecurityfilecertificate)| -|[plugin.security.file.key](#pluginsecurityfilekey)|[plugin.security.pkcs11.driver_file](#pluginsecuritypkcs11driver_file)| -|[plugin.security.pkcs11.slot](#pluginsecuritypkcs11slot)|[plugin.security.provider](#pluginsecurityprovider)| -|[plugin.yaml](#pluginyaml)|[publish_timeout](#publish_timeout)| -|[registerinterval](#registerinterval)|[registration](#registration)| -|[registration_collective](#registration_collective)|[registration_splay](#registration_splay)| -|[rpcaudit](#rpcaudit)|[rpcauditprovider](#rpcauditprovider)| -|[rpcauthorization](#rpcauthorization)|[rpcauthprovider](#rpcauthprovider)| -|[rpclimitmethod](#rpclimitmethod)|[securityprovider](#securityprovider)| -|[soft_shutdown](#soft_shutdown)|[soft_shutdown_timeout](#soft_shutdown_timeout)| -|[threaded](#threaded)|[ttl](#ttl)| +|[plugin.choria.security.request_signing_certificate](#pluginchoriasecurityrequest_signing_certificate)|[plugin.choria.security.serializer](#pluginchoriasecurityserializer)| +|[plugin.choria.server.provision](#pluginchoriaserverprovision)|[plugin.choria.srv_domain](#pluginchoriasrv_domain)| +|[plugin.choria.ssldir](#pluginchoriassldir)|[plugin.choria.stats_address](#pluginchoriastats_address)| +|[plugin.choria.stats_port](#pluginchoriastats_port)|[plugin.choria.status_file_path](#pluginchoriastatus_file_path)| +|[plugin.choria.status_update_interval](#pluginchoriastatus_update_interval)|[plugin.choria.use_srv](#pluginchoriause_srv)| +|[plugin.nats.credentials](#pluginnatscredentials)|[plugin.nats.ngs](#pluginnatsngs)| +|[plugin.nats.pass](#pluginnatspass)|[plugin.nats.user](#pluginnatsuser)| +|[plugin.scout.overrides](#pluginscoutoverrides)|[plugin.scout.tags](#pluginscouttags)| +|[plugin.security.always_overwrite_cache](#pluginsecurityalways_overwrite_cache)|[plugin.security.certmanager.alt_names](#pluginsecuritycertmanageralt_names)| +|[plugin.security.certmanager.issuer](#pluginsecuritycertmanagerissuer)|[plugin.security.certmanager.namespace](#pluginsecuritycertmanagernamespace)| +|[plugin.security.certmanager.replace](#pluginsecuritycertmanagerreplace)|[plugin.security.cipher_suites](#pluginsecuritycipher_suites)| +|[plugin.security.client_anon_tls](#pluginsecurityclient_anon_tls)|[plugin.security.ecc_curves](#pluginsecurityecc_curves)| +|[plugin.security.file.ca](#pluginsecurityfileca)|[plugin.security.file.cache](#pluginsecurityfilecache)| +|[plugin.security.file.certificate](#pluginsecurityfilecertificate)|[plugin.security.file.key](#pluginsecurityfilekey)| +|[plugin.security.pkcs11.driver_file](#pluginsecuritypkcs11driver_file)|[plugin.security.pkcs11.slot](#pluginsecuritypkcs11slot)| +|[plugin.security.provider](#pluginsecurityprovider)|[plugin.yaml](#pluginyaml)| +|[publish_timeout](#publish_timeout)|[registerinterval](#registerinterval)| +|[registration](#registration)|[registration_collective](#registration_collective)| +|[registration_splay](#registration_splay)|[rpcaudit](#rpcaudit)| +|[rpcauditprovider](#rpcauditprovider)|[rpcauthorization](#rpcauthorization)| +|[rpcauthprovider](#rpcauthprovider)|[rpclimitmethod](#rpclimitmethod)| +|[securityprovider](#securityprovider)|[soft_shutdown](#soft_shutdown)| +|[soft_shutdown_timeout](#soft_shutdown_timeout)|[threaded](#threaded)| +|[ttl](#ttl)|[](#)| ## activate_agents @@ -631,6 +632,12 @@ Path to the token used to access a Central Authenticator URL to the Signing Service +## plugin.choria.security.request_signing_certificate + + * **Type:** string + +The public certificate of the key used to sign the JWTs in the Signing Service + ## plugin.choria.security.serializer * **Type:** string diff --git a/broker/network/ipauth.go b/broker/network/ipauth.go index 997c2b8ab..d9824261a 100644 --- a/broker/network/ipauth.go +++ b/broker/network/ipauth.go @@ -1,30 +1,65 @@ package network import ( + "crypto/md5" + "crypto/rsa" + "fmt" + "io/ioutil" "net" "strings" + "github.com/dgrijalva/jwt-go" "github.com/nats-io/nats-server/v2/server" "github.com/sirupsen/logrus" ) -// IPAuth implements gnatsd server.Authentication interface and +// IPAuth implements Nats Server server.Authentication interface and // allows IP limits to be configured, connections that do not match // the configured IP or CIDRs are not allowed to publish to the -// network targets used by clients to request actions on nodes +// network targets used by clients to request actions on nodes. +// +// Additionally when the server is running in a mode where anonymous +// TLS connections is accepted then servers are entirely denied and +// clients are allowed but restricted based on the JWT issued by the +// AAA Service. type IPAuth struct { allowList []string + anonTLS bool denyServers bool + jwtSigner string log *logrus.Entry } // Check checks and registers the incoming connection func (a *IPAuth) Check(c server.ClientAuthentication) (verified bool) { user := a.createUser(c) - remote := c.RemoteAddress() - if remote != nil && !a.remoteInClientAllowList(c.RemoteAddress()) { + jwts := c.GetOpts().Token + caller := "" + + var err error + + if a.anonTLS { + if remote == nil { + a.log.Warn("Denying unknown remote client while in AnonTLS mode") + return false + } + + caller, err = a.parseAnonTLSJWTUser(jwts) + if err != nil { + a.log.Warnf("Could not parse JWT from %s, denying client: %s", remote.String(), err) + return false + } + } + + // only if allow lists are set else its a noop and all traffic is passed + switch { + case a.remoteInClientAllowList(remote): + a.setClientPermissions(user, caller) + + case len(a.allowList) > 0: a.setServerPermissions(user) + } c.RegisterUser(user) @@ -32,15 +67,99 @@ func (a *IPAuth) Check(c server.ClientAuthentication) (verified bool) { return true } +func (a *IPAuth) parseAnonTLSJWTUser(jwts string) (string, error) { + if a.jwtSigner == "" { + return "", fmt.Errorf("anonymous TLS JWT Signer not set in plugin.choria.security.request_signing_certificate, denying all clients") + } + + if jwts == "" { + return "", fmt.Errorf("no JWT received") + } + + signKey, err := a.jwtSignerKey() + if err != nil { + return "", fmt.Errorf("signing key error: %s", err) + } + + token, err := jwt.Parse(jwts, func(token *jwt.Token) (interface{}, error) { + return signKey, nil + }) + if err != nil { + return "", fmt.Errorf("invalid JWT: %s", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("invalid claims") + } + + err = claims.Valid() + if err != nil { + return "", fmt.Errorf("invalid claims") + } + + caller, ok := claims["callerid"].(string) + if !ok { + return "", fmt.Errorf("no callerid in claims") + } + + if caller == "" { + return "", fmt.Errorf("empty callerid in claims") + } + + return caller, nil +} + +func (a *IPAuth) jwtSignerKey() (*rsa.PublicKey, error) { + certBytes, err := ioutil.ReadFile(a.jwtSigner) + if err != nil { + return nil, err + } + + signKey, err := jwt.ParseRSAPublicKeyFromPEM(certBytes) + if err != nil { + return nil, err + } + + return signKey, nil +} + +func (a *IPAuth) setClientPermissions(user *server.User, caller string) { + if !a.anonTLS { + return + } + + replys := "*.reply.>" + if caller != "" { + replys = fmt.Sprintf("*.reply.%x.>", md5.Sum([]byte(caller))) + } + + user.Permissions.Subscribe = &server.SubjectPermission{ + Allow: []string{ + replys, + }, + } + + user.Permissions.Publish = &server.SubjectPermission{ + Allow: []string{ + "*.broadcast.agent.>", + "*.node.>", + "choria.federation.*.federation", + }, + } +} + func (a *IPAuth) setServerPermissions(user *server.User) { + matchAll := []string{">"} + switch { case a.denyServers: user.Permissions.Subscribe = &server.SubjectPermission{ - Deny: []string{">"}, + Deny: matchAll, } user.Permissions.Publish = &server.SubjectPermission{ - Deny: []string{">"}, + Deny: matchAll, } default: @@ -53,9 +172,7 @@ func (a *IPAuth) setServerPermissions(user *server.User) { } user.Permissions.Publish = &server.SubjectPermission{ - Allow: []string{ - ">", - }, + Allow: matchAll, Deny: []string{ "*.broadcast.agent.>", diff --git a/broker/network/ipauth_test.go b/broker/network/ipauth_test.go index a90cbbf28..84d2c72c1 100644 --- a/broker/network/ipauth_test.go +++ b/broker/network/ipauth_test.go @@ -1,9 +1,19 @@ package network import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "io/ioutil" + "math/big" "net" + "path/filepath" + "time" + "github.com/dgrijalva/jwt-go" "github.com/nats-io/nats-server/v2/server" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -100,6 +110,158 @@ var _ = Describe("Network Broker/IPAuth", func() { }) }) + Describe("parseAnonTLSJWTUser", func() { + var ( + td string + err error + privateKey *rsa.PrivateKey + ) + + BeforeEach(func() { + td, err = ioutil.TempDir("", "") + Expect(err).ToNot(HaveOccurred()) + + privateKey, err = rsa.GenerateKey(rand.Reader, 2048) + Expect(err).ToNot(HaveOccurred()) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 180), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + Expect(err).ToNot(HaveOccurred()) + + out := &bytes.Buffer{} + + pem.Encode(out, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + err = ioutil.WriteFile(filepath.Join(td, "public.pem"), out.Bytes(), 0600) + Expect(err).ToNot(HaveOccurred()) + + out.Reset() + + blk := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + pem.Encode(out, blk) + + err = ioutil.WriteFile(filepath.Join(td, "private.pem"), out.Bytes(), 0600) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Should fail without a cert", func() { + _, err := auth.parseAnonTLSJWTUser("") + Expect(err).To(MatchError("anonymous TLS JWT Signer not set in plugin.choria.security.request_signing_certificate, denying all clients")) + }) + + It("Should fail for empty JWTs", func() { + auth.jwtSigner = "testdata/public.pem" + _, err := auth.parseAnonTLSJWTUser("") + Expect(err).To(MatchError("no JWT received")) + }) + + It("Should verify JWTs", func() { + auth.jwtSigner = filepath.Join(td, "public.pem") + claims := map[string]interface{}{ + "exp": time.Now().UTC().Add(-time.Hour).Unix(), + "nbf": time.Now().UTC().Add(-1 * time.Minute).Unix(), + "iat": time.Now().UTC().Unix(), + "iss": "Ginkgo", + "callerid": "up=ginkgo", + "sub": "up=ginkgo", + } + + token := jwt.NewWithClaims(jwt.GetSigningMethod("RS512"), jwt.MapClaims(claims)) + signed, err := token.SignedString(privateKey) + Expect(err).ToNot(HaveOccurred()) + caller, err := auth.parseAnonTLSJWTUser(signed) + Expect(err).To(MatchError("invalid JWT: Token is expired")) + Expect(caller).To(Equal("")) + }) + + It("Should detect missing callers", func() { + auth.jwtSigner = filepath.Join(td, "public.pem") + claims := map[string]interface{}{ + "exp": time.Now().UTC().Add(time.Hour).Unix(), + "nbf": time.Now().UTC().Add(-1 * time.Minute).Unix(), + "iat": time.Now().UTC().Unix(), + "iss": "Ginkgo", + "sub": "up=ginkgo", + } + + token := jwt.NewWithClaims(jwt.GetSigningMethod("RS512"), jwt.MapClaims(claims)) + signed, err := token.SignedString(privateKey) + Expect(err).ToNot(HaveOccurred()) + caller, err := auth.parseAnonTLSJWTUser(signed) + Expect(err).To(MatchError("no callerid in claims")) + Expect(caller).To(Equal("")) + }) + + It("Should extract the caller", func() { + auth.jwtSigner = filepath.Join(td, "public.pem") + claims := map[string]interface{}{ + "exp": time.Now().UTC().Add(time.Hour).Unix(), + "nbf": time.Now().UTC().Add(-1 * time.Minute).Unix(), + "iat": time.Now().UTC().Unix(), + "iss": "Ginkgo", + "callerid": "up=ginkgo", + "sub": "up=ginkgo", + } + + token := jwt.NewWithClaims(jwt.GetSigningMethod("RS512"), jwt.MapClaims(claims)) + signed, err := token.SignedString(privateKey) + Expect(err).ToNot(HaveOccurred()) + caller, err := auth.parseAnonTLSJWTUser(signed) + Expect(err).ToNot(HaveOccurred()) + Expect(caller).To(Equal("up=ginkgo")) + }) + }) + + Describe("setClientPermissions", func() { + It("Should do nothing when not in anonymous tls mode", func() { + auth.anonTLS = false + auth.setClientPermissions(user, "") + Expect(user.Permissions.Subscribe).To(BeNil()) + Expect(user.Permissions.Publish).To(BeNil()) + }) + + It("Should support caller private reply subjects", func() { + auth.anonTLS = true + auth.setClientPermissions(user, "u=ginkgo") + Expect(user.Permissions.Subscribe).To(Equal(&server.SubjectPermission{ + Allow: []string{"*.reply.0f47cbbd2accc01a51e57261d6e64b8b.>"}, + })) + Expect(user.Permissions.Publish).To(Equal(&server.SubjectPermission{ + Allow: []string{ + "*.broadcast.agent.>", + "*.node.>", + "choria.federation.*.federation", + }, + })) + }) + + It("Should support standard reply subjects", func() { + auth.anonTLS = true + auth.setClientPermissions(user, "") + Expect(user.Permissions.Subscribe).To(Equal(&server.SubjectPermission{ + Allow: []string{"*.reply.>"}, + })) + Expect(user.Permissions.Publish).To(Equal(&server.SubjectPermission{ + Allow: []string{ + "*.broadcast.agent.>", + "*.node.>", + "choria.federation.*.federation", + }, + })) + }) + }) + Describe("setServerPermissions", func() { It("Should set correct permissions", func() { auth.setServerPermissions(user) diff --git a/broker/network/network.go b/broker/network/network.go index c398db133..8029b495d 100644 --- a/broker/network/network.go +++ b/broker/network/network.go @@ -90,12 +90,12 @@ func NewServer(c ChoriaFramework, bi BuildInfoProvider, debug bool) (s *Server, s.opts.HTTPPort = s.config.Choria.StatsPort } - if len(s.config.Choria.NetworkAllowedClientHosts) > 0 { - s.opts.CustomClientAuthentication = &IPAuth{ - allowList: s.config.Choria.NetworkAllowedClientHosts, - log: s.choria.Logger("ipauth"), - denyServers: s.config.Choria.NetworkDenyServers, - } + s.opts.CustomClientAuthentication = &IPAuth{ + allowList: s.config.Choria.NetworkAllowedClientHosts, + log: s.choria.Logger("ipauth"), + denyServers: s.config.Choria.NetworkDenyServers, + anonTLS: s.config.Choria.NetworkClientTLSAnon, + jwtSigner: s.config.Choria.RemoteSignerSigningCert, } err = s.setupAccounts() @@ -217,11 +217,13 @@ func (s *Server) setupTLS() (err error) { } if !s.config.Choria.NetworkDenyServers { - return fmt.Errorf("can only configure anonymous TLS for client connections when servers are denied using plugin.choria.network.deny_server_connections") + s.log.Warnf("Disabling connections from Servers while in Anon TLS mode") + s.config.Choria.NetworkDenyServers = true } if len(s.config.Choria.NetworkAllowedClientHosts) == 0 { - return fmt.Errorf("can only configure anonymous TLS for client connections when an allow list of client hosts is set using plugin.choria.network.client_hosts") + s.log.Warnf("Adding 0.0.0.0/0 to client hosts list, override using plugin.choria.network.client_hosts") + s.config.Choria.NetworkAllowedClientHosts = []string{"0.0.0.0/0"} } s.log.Warnf("Configuring anonymous TLS for client connections") diff --git a/broker/network/network_test.go b/broker/network/network_test.go index b08f5757b..990f58a60 100644 --- a/broker/network/network_test.go +++ b/broker/network/network_test.go @@ -44,7 +44,7 @@ var _ = Describe("Network Broker", func() { logger = logrus.NewEntry(logrus.New()) logger.Logger.SetLevel(logrus.DebugLevel) - logger.Logger.Out = ioutil.Discard + logger.Logger.Out = GinkgoWriter fw.EXPECT().Configuration().Return(cfg).AnyTimes() fw.EXPECT().Logger(gomock.Any()).Return(logger).AnyTimes() @@ -142,7 +142,7 @@ var _ = Describe("Network Broker", func() { fw.EXPECT().TLSConfig().Return(&tls.Config{}, nil) fw.EXPECT().NetworkBrokerPeers().Return(srvcache.NewServers(), nil) - fw.EXPECT().Logger(gomock.Any()).Return(logger) + fw.EXPECT().Logger(gomock.Any()).Return(logger).AnyTimes() }) It("Should require a name and remotes", func() { @@ -221,7 +221,7 @@ var _ = Describe("Network Broker", func() { fw.EXPECT().TLSConfig().Return(&tls.Config{}, nil) fw.EXPECT().NetworkBrokerPeers().Return(srvcache.NewServers(), nil) - fw.EXPECT().Logger(gomock.Any()).Return(logger) + fw.EXPECT().Logger(gomock.Any()).Return(logger).AnyTimes() }) It("Should support basic listening only leafnodes mode", func() { diff --git a/choria/connection.go b/choria/connection.go index b1fb9ed3b..ff987ddcf 100644 --- a/choria/connection.go +++ b/choria/connection.go @@ -2,6 +2,7 @@ package choria import ( "context" + "crypto/md5" "crypto/tls" "fmt" "net/url" @@ -91,7 +92,7 @@ type channelSubscription struct { quit chan interface{} } -// Connection is a actual NATS connectoin handler, it implements Connector +// Connection is a actual NATS connection handler, it implements Connector type Connection struct { servers func() (srvcache.Servers, error) name string @@ -104,6 +105,8 @@ type Connection struct { outbox chan *nats.Msg subMu sync.Mutex conMu sync.Mutex + token string + uniqueId string } var ( @@ -149,12 +152,12 @@ func (m *ConnectorMessage) Bytes() []byte { // NewConnector creates a new NATS connector // // It will attempt to connect to the given servers and will keep trying till it manages to do so -func (fw *Framework) NewConnector(ctx context.Context, servers func() (srvcache.Servers, error), name string, logger *log.Entry) (conn Connector, err error) { +func (fw *Framework) NewConnector(ctx context.Context, servers func() (srvcache.Servers, error), name string, logger *log.Entry) (Connector, error) { if name == "" { name = fw.Config.Identity } - conn = &Connection{ + conn := &Connection{ name: name, servers: servers, logger: logger.WithField("connection", name), @@ -165,7 +168,19 @@ func (fw *Framework) NewConnector(ctx context.Context, servers func() (srvcache. outbox: make(chan *nats.Msg, 1000), } - err = conn.Connect(ctx) + if fw.Config.Choria.ClientAnonTLS && !fw.Config.InitiatedByServer { + caller, id, token, err := fw.UniqueIDFromUnverifiedToken() + if err != nil { + return nil, fmt.Errorf("could not parse JWT: %s", err) + } + + conn.logger.Infof("Setting JWT token and unique reply queues based on JWT for %q", caller) + + conn.token = token + conn.uniqueId = id + } + + err := conn.Connect(ctx) return conn, err } @@ -492,7 +507,7 @@ func (conn *Connection) AgentBroadcastTarget(collective string, agent string) st } func ReplyTarget(msg *Message, requestid string) string { - return fmt.Sprintf("%s.reply.%s.%s", msg.Collective(), msg.SenderID, requestid) + return fmt.Sprintf("%s.reply.%s.%s", msg.Collective(), fmt.Sprintf("%x", md5.Sum([]byte(msg.CallerID))), requestid) } func (conn *Connection) ReplyTarget(msg *Message) (string, error) { @@ -601,6 +616,13 @@ func (conn *Connection) Connect(ctx context.Context) (err error) { options = append(options, nats.Secure(tlsc)) + token, err := conn.choria.SignerToken() + if err != nil { + return fmt.Errorf("no signer token found while connecting to an anonymous TLS server: %s", err) + } + + options = append(options, nats.Token(token)) + case !(conn.config.DisableTLS || conn.choria.ShouldUseNGS()): tlsc, err := conn.choria.TLSConfig() if err != nil { diff --git a/choria/framework.go b/choria/framework.go index 890e5feb7..a6cf99de0 100644 --- a/choria/framework.go +++ b/choria/framework.go @@ -2,9 +2,11 @@ package choria import ( "context" + "crypto/md5" "errors" "fmt" "io" + "io/ioutil" "math/rand" "net" "os" @@ -12,6 +14,8 @@ import ( "sync" "time" + "github.com/dgrijalva/jwt-go" + "github.com/choria-io/go-choria/protocol" certmanagersec "github.com/choria-io/go-choria/providers/security/certmanager" @@ -538,6 +542,11 @@ func (fw *Framework) UniqueID() string { // CallerID determines the cert based callerid func (fw *Framework) CallerID() string { + caller, _, _, err := fw.UniqueIDFromUnverifiedToken() + if err == nil { + return caller + } + return fmt.Sprintf("choria=%s", fw.Certname()) } @@ -566,3 +575,60 @@ func (fw *Framework) DisableTLSVerify() bool { func (fw *Framework) Configuration() *config.Config { return fw.Config } + +func (fw *Framework) UniqueIDFromUnverifiedToken() (caller string, id string, token string, err error) { + t, claims, err := fw.ParseSignerTokenUnverified() + if err != nil { + return "", "", "", err + } + + caller, ok := claims["callerid"].(string) + if !ok { + return "", "", "", fmt.Errorf("invalid callerid in token claims") + } + + return caller, fmt.Sprintf("%x", md5.Sum([]byte(caller))), t.Raw, nil +} + +func (fw *Framework) ParseSignerTokenUnverified() (token *jwt.Token, claims jwt.MapClaims, err error) { + ts, err := fw.SignerToken() + if err != nil { + return nil, nil, err + } + + claims = jwt.MapClaims{} + token, _, err = new(jwt.Parser).ParseUnverified(ts, &claims) + if err != nil { + return nil, nil, err + } + + err = claims.Valid() + if err != nil { + return nil, nil, err + } + + return token, claims, nil +} + +// Retrieves the AAA token used for signing requests +func (fw *Framework) SignerToken() (token string, err error) { + if fw.Config.Choria.RemoteSignerTokenFile == "" && fw.Config.Choria.RemoteSignerTokenEnvironment == "" { + return "", fmt.Errorf("no token file or environment variable is defined") + } + + if fw.Config.Choria.RemoteSignerTokenFile != "" { + tb, err := ioutil.ReadFile(fw.Config.Choria.RemoteSignerTokenFile) + if err != nil { + return "", fmt.Errorf("could not read token file: %v", err) + } + + return strings.TrimSpace(string(tb)), err + } + + token = os.Getenv(fw.Config.Choria.RemoteSignerTokenEnvironment) + if token == "" { + return "", fmt.Errorf("did not find a token in environment variable %s", fw.Config.Choria.RemoteSignerTokenEnvironment) + } + + return strings.TrimSpace(token), nil +} diff --git a/choria/framework_test.go b/choria/framework_test.go index 9e9825fc2..95e219085 100644 --- a/choria/framework_test.go +++ b/choria/framework_test.go @@ -1,11 +1,24 @@ package choria import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io/ioutil" + "math/big" "os" + "path/filepath" + "strings" "testing" + "time" "github.com/choria-io/go-choria/build" "github.com/choria-io/go-choria/config" + "github.com/dgrijalva/jwt-go" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) @@ -17,7 +30,7 @@ func TestChoria(t *testing.T) { } var _ = Describe("Choria", func() { - var _ = Describe("NewChoria", func() { + Describe("NewChoria", func() { It("Should initialize choria correctly", func() { cfg := config.NewConfigForTests() cfg.Choria.SSLDir = "/nonexisting" @@ -29,7 +42,147 @@ var _ = Describe("Choria", func() { }) }) - var _ = Describe("ProvisionMode", func() { + Describe("JWT", func() { + var ( + fw *Framework + cfg *config.Config + err error + privateKey *rsa.PrivateKey + td string + ) + + BeforeEach(func() { + td, err = ioutil.TempDir("", "") + Expect(err).ToNot(HaveOccurred()) + + cfg = config.NewConfigForTests() + cfg.Choria.SSLDir = "/nonexisting" + cfg.DisableSecurityProviderVerify = true + + fw, err = NewWithConfig(cfg) + Expect(err).ToNot(HaveOccurred()) + + privateKey, err = rsa.GenerateKey(rand.Reader, 2048) + Expect(err).ToNot(HaveOccurred()) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 180), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + Expect(err).ToNot(HaveOccurred()) + + out := &bytes.Buffer{} + + pem.Encode(out, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + err = ioutil.WriteFile(filepath.Join(td, "public.pem"), out.Bytes(), 0600) + Expect(err).ToNot(HaveOccurred()) + + out.Reset() + + blk := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + pem.Encode(out, blk) + + err = ioutil.WriteFile(filepath.Join(td, "private.pem"), out.Bytes(), 0600) + Expect(err).ToNot(HaveOccurred()) + + jwtpath := filepath.Join(td, "good.jwt") + + claims := map[string]interface{}{ + "exp": time.Now().UTC().Add(time.Hour).Unix(), + "nbf": time.Now().UTC().Add(-1 * time.Minute).Unix(), + "iat": time.Now().UTC().Unix(), + "iss": "Ginkgo", + "callerid": "up=ginkgo", + "sub": "up=ginkgo", + } + + t := jwt.NewWithClaims(jwt.GetSigningMethod("RS512"), jwt.MapClaims(claims)) + signed, err := t.SignedString(privateKey) + Expect(err).ToNot(HaveOccurred()) + + err = ioutil.WriteFile(jwtpath, []byte(signed), 0600) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + os.RemoveAll(td) + }) + + Describe("UniqueIDFromUnverifiedToken", func() { + It("Should extract the correct items", func() { + cfg.Choria.RemoteSignerTokenFile = filepath.Join(td, "good.jwt") + caller, id, token, err := fw.UniqueIDFromUnverifiedToken() + Expect(err).ToNot(HaveOccurred()) + + expectedT, err := ioutil.ReadFile(cfg.Choria.RemoteSignerTokenFile) + Expect(err).ToNot(HaveOccurred()) + + Expect(token).To(Equal(strings.TrimSpace(string(expectedT)))) + Expect(id).To(Equal("e33bf0376d4accbb4a8fd24b2f840b2e")) + Expect(caller).To(Equal("up=ginkgo")) + }) + }) + + Describe("ParseSignerTokenUnverified", func() { + It("Should extract the correct token", func() { + cfg.Choria.RemoteSignerTokenFile = filepath.Join(td, "good.jwt") + token, claims, err := fw.ParseSignerTokenUnverified() + Expect(err).ToNot(HaveOccurred()) + + expectedT, err := ioutil.ReadFile(cfg.Choria.RemoteSignerTokenFile) + Expect(err).ToNot(HaveOccurred()) + Expect(strings.TrimSpace(token.Raw)).To(Equal(strings.TrimSpace(string(expectedT)))) + + Expect(claims["callerid"]).To(Equal("up=ginkgo")) + }) + + It("Should handle missing files", func() { + cfg.Choria.RemoteSignerTokenFile = "testdata/missing.jwt" + _, _, err := fw.ParseSignerTokenUnverified() + Expect(err.Error()).To(MatchRegexp("could not read token file")) + }) + }) + + Describe("SignerToken", func() { + It("Should error when there is no way to find a token", func() { + t, err := fw.SignerToken() + Expect(t).To(BeEmpty()) + Expect(err).To(MatchError("no token file or environment variable is defined")) + }) + + It("Should support environment tokens", func() { + cfg.Choria.RemoteSignerTokenEnvironment = "GINKGO_TOKEN" + os.Setenv("GINKGO_TOKEN", "FOOFOO") + t, err := fw.SignerToken() + Expect(err).ToNot(HaveOccurred()) + Expect(t).To(Equal("FOOFOO")) + }) + + It("Should support file tokens", func() { + tf, err := ioutil.TempFile("", "") + Expect(err).ToNot(HaveOccurred()) + defer os.Remove(tf.Name()) + + fmt.Fprintf(tf, "FOOFOO") + cfg.Choria.RemoteSignerTokenFile = tf.Name() + t, err := fw.SignerToken() + Expect(err).ToNot(HaveOccurred()) + Expect(t).To(Equal("FOOFOO")) + }) + }) + }) + + Describe("ProvisionMode", func() { It("Should be on only in the Server", func() { c := config.NewConfigForTests() c.Choria.SSLDir = "/nonexisting" diff --git a/choria/message.go b/choria/message.go index 4dcab676d..864725e01 100644 --- a/choria/message.go +++ b/choria/message.go @@ -79,15 +79,18 @@ func NewMessage(payload string, agent string, collective string, msgType string, return } + cfg := choria.Configuration() + msg = &Message{ - Payload: payload, - RequestID: id, - TTL: choria.Config.TTL, - DiscoveredHosts: []string{}, - SenderID: choria.Config.Identity, - CallerID: choria.CallerID(), - Filter: protocol.NewFilter(), - choria: choria, + Payload: payload, + RequestID: id, + TTL: cfg.TTL, + DiscoveredHosts: []string{}, + SenderID: cfg.Identity, + CallerID: choria.CallerID(), + Filter: protocol.NewFilter(), + choria: choria, + shouldCacheTransport: cfg.CacheBatchedTransports, } err = msg.SetType(msgType) @@ -112,10 +115,6 @@ func NewMessage(payload string, agent string, collective string, msgType string, } } - if choria.Configuration().CacheBatchedTransports { - msg.shouldCacheTransport = true - } - _, err = msg.Validate() return diff --git a/config/choria.go b/config/choria.go index 8a4480f4e..c611c5c3f 100644 --- a/config/choria.go +++ b/config/choria.go @@ -86,6 +86,7 @@ type ChoriaPluginConfig struct { SecurityAlwaysOverwriteCache bool `confkey:"plugin.security.always_overwrite_cache" default:"false"` // Always store new Public Keys to the cache overwriting existing ones RemoteSignerTokenFile string `confkey:"plugin.choria.security.request_signer.token_file" type:"path_string" url:"https://github.com/choria-io/aaasvc"` // Path to the token used to access a Central Authenticator RemoteSignerTokenEnvironment string `confkey:"plugin.choria.security.request_signer.token_environment" url:"https://github.com/choria-io/aaasvc"` // Environment variable to store Central Authenticator tokens + RemoteSignerSigningCert string `confkey:"plugin.choria.security.request_signing_certificate"` // The public certificate of the key used to sign the JWTs in the Signing Service RemoteSignerURL string `confkey:"plugin.choria.security.request_signer.url" url:"https://github.com/choria-io/aaasvc"` // URL to the Signing Service ClientAnonTLS bool `confkey:"plugin.security.client_anon_tls" default:"false"` // Use anonymous TLS to the Choria brokers from a client, also disables security provider verification - only when a remote signer is set diff --git a/config/docstrings.go b/config/docstrings.go index a4395086b..544dc799c 100644 --- a/config/docstrings.go +++ b/config/docstrings.go @@ -1,4 +1,4 @@ -// auto generated at 2020-09-09 09:26:36.265573 +0200 CEST m=+0.002437471 +// auto generated at 2020-09-11 16:22:00.242682 +0200 CEST m=+0.002106897 package config @@ -93,6 +93,7 @@ var docStrings = map[string]string{ "plugin.security.always_overwrite_cache": "Always store new Public Keys to the cache overwriting existing ones", "plugin.choria.security.request_signer.token_file": "Path to the token used to access a Central Authenticator", "plugin.choria.security.request_signer.token_environment": "Environment variable to store Central Authenticator tokens", + "plugin.choria.security.request_signing_certificate": "The public certificate of the key used to sign the JWTs in the Signing Service", "plugin.choria.security.request_signer.url": "URL to the Signing Service", "plugin.security.client_anon_tls": "Use anonymous TLS to the Choria brokers from a client, also disables security provider verification - only when a remote signer is set", "plugin.security.file.certificate": "When using file security provider, the path to the public certificate", diff --git a/go.mod b/go.mod index 9c5853650..1f0c13f28 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,9 @@ require ( github.com/awesome-gocui/gocui v0.6.0 github.com/choria-io/provisioning-agent v0.8.0 github.com/cloudevents/sdk-go v1.2.0 - github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/dgrijalva/jwt-go v3.2.1-0.20200107013213-dc14462fd587+incompatible github.com/fatih/color v1.9.0 + github.com/fatih/structtag v1.2.0 // indirect github.com/ghodss/yaml v1.0.0 github.com/gofrs/uuid v3.3.0+incompatible github.com/golang/mock v1.4.4 diff --git a/go.sum b/go.sum index f9139f09b..c9a000ca6 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgrijalva/jwt-go v3.2.1-0.20200107013213-dc14462fd587+incompatible h1:CiQ/hJK0Lsc/2Gm9uMSIe7cFE+h0sbTwHuTGQkIZpio= +github.com/dgrijalva/jwt-go v3.2.1-0.20200107013213-dc14462fd587+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= @@ -112,6 +114,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= diff --git a/providers/agent/mcorpc/client/client.go b/providers/agent/mcorpc/client/client.go index 4911d93fc..932ef499c 100644 --- a/providers/agent/mcorpc/client/client.go +++ b/providers/agent/mcorpc/client/client.go @@ -263,7 +263,7 @@ func (r *RPC) setupMessage(ctx context.Context, action string, payload interface msg, err = r.fw.NewMessage(string(rpcp), r.agent, r.cfg.MainCollective, "request", nil) if err != nil { - return nil, nil, fmt.Errorf("could not create Message: %s", err) + return nil, nil, err } err = r.opts.ConfigureMessage(msg)