From e61e0143bdf04a642a5034753f1002cfd6acf569 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Mon, 15 Jun 2020 14:16:02 -0400 Subject: [PATCH] refactor/enhance jwt signing --- conventional.yaml | 29 +++++++++++------- default.yaml | 28 ++++++++++------- irc/channel.go | 9 ++---- irc/config.go | 55 +++++++++++++++++++++++---------- irc/handlers.go | 48 ++++++++++++++--------------- irc/jwt/extjwt.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 70 deletions(-) create mode 100644 irc/jwt/extjwt.go diff --git a/conventional.yaml b/conventional.yaml index d27ac698..0757f124 100644 --- a/conventional.yaml +++ b/conventional.yaml @@ -161,17 +161,6 @@ server: # - "192.168.1.1" # - "192.168.10.1/24" - # these services can integrate with the ircd using JSON Web Tokens (https://jwt.io) - # sometimes referred to with 'EXTJWT' - jwt-services: - # # service name - # call-host: - # # custom expiry length, default is 30s - # expiry-in-seconds: 45 - - # # secret string to verify the generated tokens - # secret: call-hosting-secret-token - # allow use of the RESUME extension over plaintext connections: # do not enable this unless the ircd is only accessible over internal networks allow-plaintext-resume: false @@ -790,6 +779,24 @@ roleplay: # add the real nickname, in parentheses, to the end of every roleplay message? add-suffix: true +# external services can integrate with the ircd using JSON Web Tokens (https://jwt.io). +# in effect, the server can sign a token attesting that the client is present on +# the server, is a member of a particular channel, etc. +extjwt: + # default service config (for `EXTJWT #channel`). + # expiration time for the token: + # expiration: 45s + # you can configure tokens to be signed either with HMAC and a symmetric secret: + # secret: "65PHvk0K1_sM-raTsCEhatVkER_QD8a0zVV8gG2EWcI" + # or with an RSA private key: + # #rsa-private-key-file: "extjwt.pem" + + # named services: + # services: + # "jitsi": + # expiration: 30s + # secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" + # history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback, # various autoreplay features, and the resume extension history: diff --git a/default.yaml b/default.yaml index 611be84b..9eead870 100644 --- a/default.yaml +++ b/default.yaml @@ -187,17 +187,6 @@ server: # - "192.168.1.1" # - "192.168.10.1/24" - # these services can integrate with the ircd using JSON Web Tokens (https://jwt.io) - # sometimes referred to with 'EXTJWT' - jwt-services: - # # service name - # call-host: - # # custom expiry length, default is 30s - # expiry-in-seconds: 45 - - # # secret string to verify the generated tokens - # secret: call-hosting-secret-token - # allow use of the RESUME extension over plaintext connections: # do not enable this unless the ircd is only accessible over internal networks allow-plaintext-resume: false @@ -816,6 +805,23 @@ roleplay: # add the real nickname, in parentheses, to the end of every roleplay message? add-suffix: true +# external services can integrate with the ircd using JSON Web Tokens (https://jwt.io). +# in effect, the server can sign a token attesting that the client is present on +# the server, is a member of a particular channel, etc. +extjwt: + # default service: + # expiration: 45s + # symmetric secret for HMAC signing: + # secret: "65PHvk0K1_sM-raTsCEhatVkER_QD8a0zVV8gG2EWcI" + # private key for RSA signing: + # rsa-private-key-file: "extjwt.pem" + + # named services: + # services: + # "jitsi": + # expiration: 30s + # secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" + # history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback, # various autoreplay features, and the resume extension history: diff --git a/irc/channel.go b/irc/channel.go index 842ddc39..99e1960c 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -539,16 +539,11 @@ func (channel *Channel) ClientPrefixes(client *Client, isMultiPrefix bool) strin } } -func (channel *Channel) ClientModeStrings(client *Client) (result []string) { +func (channel *Channel) ClientStatus(client *Client) (present bool, cModes modes.Modes) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() modes, present := channel.members[client] - if present { - for _, mode := range modes.AllModes() { - result = append(result, mode.String()) - } - } - return + return present, modes.AllModes() } func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool { diff --git a/irc/config.go b/irc/config.go index cc81792f..fe5c643f 100644 --- a/irc/config.go +++ b/irc/config.go @@ -27,6 +27,7 @@ import ( "github.com/oragono/oragono/irc/custime" "github.com/oragono/oragono/irc/email" "github.com/oragono/oragono/irc/isupport" + "github.com/oragono/oragono/irc/jwt" "github.com/oragono/oragono/irc/languages" "github.com/oragono/oragono/irc/ldap" "github.com/oragono/oragono/irc/logger" @@ -471,11 +472,6 @@ type TorListenersConfig struct { MaxConnectionsPerDuration int `yaml:"max-connections-per-duration"` } -type JwtServiceConfig struct { - ExpiryInSeconds int64 `yaml:"expiry-in-seconds"` - Secret string -} - // Config defines the overall configuration. type Config struct { Network struct { @@ -507,9 +503,8 @@ type Config struct { MOTDFormatting bool `yaml:"motd-formatting"` ProxyAllowedFrom []string `yaml:"proxy-allowed-from"` proxyAllowedFromNets []net.IPNet - WebIRC []webircConfig `yaml:"webirc"` - JwtServices map[string]JwtServiceConfig `yaml:"jwt-services"` - MaxSendQString string `yaml:"max-sendq"` + WebIRC []webircConfig `yaml:"webirc"` + MaxSendQString string `yaml:"max-sendq"` MaxSendQBytes int AllowPlaintextResume bool `yaml:"allow-plaintext-resume"` Compatibility struct { @@ -537,6 +532,11 @@ type Config struct { addSuffix bool } + Extjwt struct { + Default jwt.JwtServiceConfig `yaml:",inline"` + Services map[string]jwt.JwtServiceConfig `yaml:"services"` + } + Languages struct { Enabled bool Path string @@ -811,6 +811,29 @@ func (conf *Config) prepareListeners() (err error) { return nil } +func (config *Config) processExtjwt() (err error) { + // first process the default service, which may be disabled + err = config.Extjwt.Default.Postprocess() + if err != nil { + return + } + // now process the named services. it is an error if any is disabled + // also, normalize the service names to lowercase + services := make(map[string]jwt.JwtServiceConfig, len(config.Extjwt.Services)) + for service, sConf := range config.Extjwt.Services { + err := sConf.Postprocess() + if err != nil { + return err + } + if !sConf.Enabled() { + return fmt.Errorf("no keys enabled for extjwt service %s", service) + } + services[strings.ToLower(service)] = sConf + } + config.Extjwt.Services = services + return nil +} + // LoadRawConfig loads the config without doing any consistency checks or postprocessing func LoadRawConfig(filename string) (config *Config, err error) { data, err := ioutil.ReadFile(filename) @@ -927,13 +950,6 @@ func LoadConfig(filename string) (config *Config, err error) { config.Server.capValues[caps.Multiline] = multilineCapValue } - // confirm jwt config - for name, info := range config.Server.JwtServices { - if info.Secret == "" { - return nil, fmt.Errorf("Could not parse jwt-services config, %s service has no secret set", name) - } - } - // handle legacy name 'bouncer' for 'multiclient' section: if config.Accounts.Bouncer != nil { config.Accounts.Multiclient = *config.Accounts.Bouncer @@ -1153,6 +1169,11 @@ func LoadConfig(filename string) (config *Config, err error) { } } + err = config.processExtjwt() + if err != nil { + return nil, err + } + // now that all postprocessing is complete, regenerate ISUPPORT: err = config.generateISupport() if err != nil { @@ -1190,7 +1211,9 @@ func (config *Config) generateISupport() (err error) { isupport.Add("CHANTYPES", chanTypes) isupport.Add("ELIST", "U") isupport.Add("EXCEPTS", "") - isupport.Add("EXTJWT", "1") + if config.Extjwt.Default.Enabled() || len(config.Extjwt.Services) != 0 { + isupport.Add("EXTJWT", "1") + } isupport.Add("INVEX", "") isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen)) isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes))) diff --git a/irc/handlers.go b/irc/handlers.go index c0decf2d..3d558cfa 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -20,12 +20,12 @@ import ( "strings" "time" - "github.com/dgrijalva/jwt-go" "github.com/goshuirc/irc-go/ircfmt" "github.com/goshuirc/irc-go/ircmsg" "github.com/oragono/oragono/irc/caps" "github.com/oragono/oragono/irc/custime" "github.com/oragono/oragono/irc/history" + "github.com/oragono/oragono/irc/jwt" "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/sno" "github.com/oragono/oragono/irc/utils" @@ -914,8 +914,6 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res // EXTJWT [service_name] func extjwtHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - expireInSeconds := int64(30) - accountName := client.AccountName() if accountName == "*" { accountName = "" @@ -938,42 +936,42 @@ func extjwtHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re claims["channel"] = channel.Name() claims["joined"] = 0 claims["cmodes"] = []string{} - if channel.hasClient(client) { + if present, cModes := channel.ClientStatus(client); present { claims["joined"] = 1 - claims["cmodes"] = channel.ClientModeStrings(client) + var modeStrings []string + for _, cMode := range cModes { + modeStrings = append(modeStrings, string(cMode)) + } + claims["cmodes"] = modeStrings } } - // we default to a secret of `*`. if you want a real secret setup a service in the config~ - service := "*" - secret := "*" + config := server.Config() + var serviceName string + var sConfig jwt.JwtServiceConfig if 1 < len(msg.Params) { - service = strings.ToLower(msg.Params[1]) - - c := server.Config() - info, exists := c.Server.JwtServices[service] - if !exists { - rb.Add(nil, server.name, "FAIL", "EXTJWT", "NO_SUCH_SERVICE", client.t("No such service")) - return false - } - secret = info.Secret - if info.ExpiryInSeconds != 0 { - expireInSeconds = info.ExpiryInSeconds - } + serviceName = strings.ToLower(msg.Params[1]) + sConfig = config.Extjwt.Services[serviceName] + } else { + serviceName = "*" + sConfig = config.Extjwt.Default } - claims["exp"] = time.Now().Unix() + expireInSeconds - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(secret)) + if !sConfig.Enabled() { + rb.Add(nil, server.name, "FAIL", "EXTJWT", "NO_SUCH_SERVICE", client.t("No such service")) + return false + } + + tokenString, err := sConfig.Sign(claims) if err == nil { maxTokenLength := 400 for maxTokenLength < len(tokenString) { - rb.Add(nil, server.name, "EXTJWT", msg.Params[0], service, "*", tokenString[:maxTokenLength]) + rb.Add(nil, server.name, "EXTJWT", msg.Params[0], serviceName, "*", tokenString[:maxTokenLength]) tokenString = tokenString[maxTokenLength:] } - rb.Add(nil, server.name, "EXTJWT", msg.Params[0], service, tokenString) + rb.Add(nil, server.name, "EXTJWT", msg.Params[0], serviceName, tokenString) } else { rb.Add(nil, server.name, "FAIL", "EXTJWT", "UNKNOWN_ERROR", client.t("Could not generate EXTJWT token")) } diff --git a/irc/jwt/extjwt.go b/irc/jwt/extjwt.go new file mode 100644 index 00000000..803f83e5 --- /dev/null +++ b/irc/jwt/extjwt.go @@ -0,0 +1,77 @@ +// Copyright (c) 2020 Daniel Oaks +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +package jwt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "time" + + "github.com/dgrijalva/jwt-go" +) + +var ( + ErrNoKeys = errors.New("No signing keys are enabled") +) + +type MapClaims jwt.MapClaims + +type JwtServiceConfig struct { + Expiration time.Duration + Secret string + secretBytes []byte + RSAPrivateKeyFile string `yaml:"rsa-private-key-file"` + rsaPrivateKey *rsa.PrivateKey +} + +func (t *JwtServiceConfig) Postprocess() (err error) { + t.secretBytes = []byte(t.Secret) + t.Secret = "" + if t.RSAPrivateKeyFile != "" { + keyBytes, err := ioutil.ReadFile(t.RSAPrivateKeyFile) + if err != nil { + return err + } + d, _ := pem.Decode(keyBytes) + if err != nil { + return err + } + t.rsaPrivateKey, err = x509.ParsePKCS1PrivateKey(d.Bytes) + if err != nil { + privateKey, err := x509.ParsePKCS8PrivateKey(d.Bytes) + if err != nil { + return err + } + if rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey); ok { + t.rsaPrivateKey = rsaPrivateKey + } else { + return fmt.Errorf("Non-RSA key type for extjwt: %T", privateKey) + } + } + } + return nil +} + +func (t *JwtServiceConfig) Enabled() bool { + return t.Expiration != 0 && (len(t.secretBytes) != 0 || t.rsaPrivateKey != nil) +} + +func (t *JwtServiceConfig) Sign(claims MapClaims) (result string, err error) { + claims["exp"] = time.Now().Unix() + int64(t.Expiration/time.Second) + + if t.rsaPrivateKey != nil { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims)) + return token.SignedString(t.rsaPrivateKey) + } else if len(t.secretBytes) != 0 { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)) + return token.SignedString(t.secretBytes) + } else { + return "", ErrNoKeys + } +}