3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-10 22:19:31 +01:00

refactor/enhance jwt signing

This commit is contained in:
Shivaram Lingamneni 2020-06-15 14:16:02 -04:00
parent bfeba1f2f3
commit e61e0143bd
6 changed files with 176 additions and 70 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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 {

View File

@ -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)))

View File

@ -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 <target> [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"))
}

77
irc/jwt/extjwt.go Normal file
View File

@ -0,0 +1,77 @@
// Copyright (c) 2020 Daniel Oaks <daniel@danieloaks.net>
// Copyright (c) 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// 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
}
}