diff --git a/default.yaml b/default.yaml index 64f36176..d2085379 100644 --- a/default.yaml +++ b/default.yaml @@ -663,6 +663,9 @@ accounts: # if a claim is formatted as an email address, require it to have the following domain, # and then strip off the domain and use the local-part as the account name: #strip-domain: "example.com" + # optional list of `aud` claims that are acceptable + # (if omitted, the aud claim is not validated): + #validate-aud: ["irc.mydomain.com"] # channel options channels: @@ -1027,16 +1030,34 @@ 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" + # algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519) + # # hmac takes a symmetric key, rsa and eddsa take PEM-encoded private keys; + # # either way, the key can be specified either as a YAML string: + # key: "nANiZ1De4v6WnltCHN2H7Q" + # # or as a path to the file containing the key: + # #key-file: "jwt_privkey.pem" # # named services (for `EXTJWT #channel service_name`): # services: # "jitsi": # expiration: 30s - # secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" + # algorithm: "hmac" + # key: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" + +# experimental draft/AUTHTOKEN mechanism +authtoken: + enabled: false + + # only these IPs can verify tokens + verification-ip-whitelist: + - "localhost" + + #services: + # "FILEHOST": + # expiration: 5m + # url: "https://example.com/filehost" + # algorithm: "eddsa" + # key: "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHfpO1x835o9NIQA1kBkN7/Myd6wqE/m/EYJUHBC18hW\n-----END PRIVATE KEY-----" # history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback, # various autoreplay features, and the resume extension diff --git a/gencapdefs.py b/gencapdefs.py index 3860d930..54d10718 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -243,6 +243,13 @@ CAPDEFS = [ url="https://ircv3.net/specs/extensions/metadata", standard="draft IRCv3", ), + CapDef( + identifier="AuthToken", + name="draft/authtoken", + url="https://github.com/ircv3/ircv3-specifications/pull/602", + standard="proposed IRCv3", + ), + ] diff --git a/irc/caps/constants.go b/irc/caps/constants.go index 9eb2624d..1c08e22e 100644 --- a/irc/caps/constants.go +++ b/irc/caps/constants.go @@ -66,6 +66,10 @@ const ( ChathistoryTargetsBatchType = "draft/chathistory-targets" ExtendedISupportBatchType = "draft/isupport" ChathistoryEndOfPaginationTag = "draft/chathistory-end" + + // authtoken draft: https://github.com/ircv3/ircv3-specifications/pull/602 + AuthTokenBatchType = "draft/authtoken" + AuthToken005 = "draft/AUTHTOKEN" ) func init() { diff --git a/irc/caps/defs.go b/irc/caps/defs.go index fa76128c..14995794 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 38 + numCapabs = 39 // length of the uint32 array that represents the bitset: bitsetLen = 2 ) @@ -41,6 +41,10 @@ const ( // https://github.com/ircv3/ircv3-specifications/pull/435 AccountRegistration Capability = iota + // AuthToken is the proposed IRCv3 capability named "draft/authtoken": + // https://github.com/ircv3/ircv3-specifications/pull/602 + AuthToken Capability = iota + // ChannelRename is the draft IRCv3 capability named "draft/channel-rename": // https://ircv3.net/specs/extensions/channel-rename ChannelRename Capability = iota @@ -176,6 +180,7 @@ var ( "cap-notify", "chghost", "draft/account-registration", + "draft/authtoken", "draft/channel-rename", "draft/chathistory", "draft/event-playback", diff --git a/irc/client.go b/irc/client.go index 395689d2..c2b5fa30 100644 --- a/irc/client.go +++ b/irc/client.go @@ -214,12 +214,14 @@ type Session struct { zncPlaybackTimes *zncPlaybackTimes autoreplayMissedSince time.Time - batch MultilineBatch + multilineBatch MultilineBatch webPushEndpoint string // goroutine-local: web push endpoint registered by the current session metadataSubscriptions utils.HashSet[string] metadataPreregVals map[string]string + + tokenValidateBatch *TokenValidateBatch } // MultilineBatch tracks the state of a client-to-server multiline batch. @@ -233,13 +235,20 @@ type MultilineBatch struct { tags map[string]string } +type TokenValidateBatch struct { + label string + responseLabel string + service string + buf strings.Builder +} + // Starts a multiline batch, failing if there's one already open func (s *Session) StartMultilineBatch(label, target, responseLabel string, tags map[string]string) (err error) { - if s.batch.label != "" { + if s.multilineBatch.label != "" { return errInvalidMultilineBatch } - s.batch.label, s.batch.target, s.batch.responseLabel, s.batch.tags = label, target, responseLabel, tags + s.multilineBatch.label, s.multilineBatch.target, s.multilineBatch.responseLabel, s.multilineBatch.tags = label, target, responseLabel, tags s.fakelag.Suspend() return } @@ -247,8 +256,8 @@ func (s *Session) StartMultilineBatch(label, target, responseLabel string, tags // Closes a multiline batch unconditionally; returns the batch and whether // it was validly terminated (pass "" as the label if you don't care about the batch) func (s *Session) EndMultilineBatch(label string) (batch MultilineBatch, err error) { - batch = s.batch - s.batch = MultilineBatch{} + batch = s.multilineBatch + s.multilineBatch = MultilineBatch{} s.fakelag.Unsuspend() // heuristics to estimate how much data they used while fakelag was suspended @@ -402,6 +411,7 @@ func (server *Server) RunClient(conn IRCConn, cookies []RequestCookie) { connID: connID, cookies: cookies, } + cookies = nil session.sasl.Initialize() client.sessions = []*Session{session} diff --git a/irc/commands.go b/irc/commands.go index b8be684d..724705e3 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -11,11 +11,10 @@ import ( // Command represents a command accepted from a client. type Command struct { - handler func(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool - usablePreReg bool - allowedInBatch bool // allowed in client-to-server batches - minParams int - capabs []string + handler func(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool + usablePreReg bool + minParams int + capabs []string } // resolveCommand returns the command to execute in response to a user input line. @@ -56,11 +55,17 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, rb.target.t("Not enough parameters")) return false } - if session.batch.label != "" && !cmd.allowedInBatch { + // C2S batch restrictions, custom per C2S batch type: + if session.multilineBatch.label != "" && !(msg.Command == "BATCH" || msg.Command == "PRIVMSG" || msg.Command == "NOTICE") { rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Command not allowed during a multiline batch")) session.EndMultilineBatch("") return false } + if session.tokenValidateBatch != nil && !(msg.Command == "BATCH" || msg.Command == "TOKEN") { + rb.Add(nil, server.name, "FAIL", "BATCH", "INVALID_PARAMS", client.t("Command not allowed during a TOKEN VALIDATE batch")) + session.tokenValidateBatch = nil + return false + } return cmd.handler(server, client, msg, rb) }() @@ -112,9 +117,8 @@ func init() { minParams: 0, }, "BATCH": { - handler: batchHandler, - minParams: 1, - allowedInBatch: true, + handler: batchHandler, + minParams: 1, }, "CAP": { handler: capHandler, @@ -236,9 +240,8 @@ func init() { minParams: 1, }, "NOTICE": { - handler: messageHandler, - minParams: 2, - allowedInBatch: true, + handler: messageHandler, + minParams: 2, }, "NPC": { handler: npcHandler, @@ -276,19 +279,34 @@ func init() { minParams: 1, }, "PRIVMSG": { - handler: messageHandler, - minParams: 2, - allowedInBatch: true, + handler: messageHandler, + minParams: 2, }, - "RELAYMSG": { - handler: relaymsgHandler, - minParams: 3, + "QUIT": { + handler: quitHandler, + usablePreReg: true, + minParams: 0, + }, + "REDACT": { + handler: redactHandler, + minParams: 2, }, "REGISTER": { handler: registerHandler, minParams: 3, usablePreReg: true, }, + + "REHASH": { + handler: rehashHandler, + minParams: 0, + capabs: []string{"rehash"}, + }, + + "RELAYMSG": { + handler: relaymsgHandler, + minParams: 3, + }, "RENAME": { handler: renameHandler, minParams: 2, @@ -323,24 +341,15 @@ func init() { handler: messageHandler, minParams: 1, }, - "QUIT": { - handler: quitHandler, - usablePreReg: true, - minParams: 0, - }, - "REDACT": { - handler: redactHandler, - minParams: 2, - }, - "REHASH": { - handler: rehashHandler, - minParams: 0, - capabs: []string{"rehash"}, - }, "TIME": { handler: timeHandler, minParams: 0, }, + "TOKEN": { + handler: tokenHandler, + minParams: 1, + usablePreReg: true, + }, "TOPIC": { handler: topicHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index 8b819af3..fbdea490 100644 --- a/irc/config.go +++ b/irc/config.go @@ -627,6 +627,8 @@ type Config struct { Services map[string]jwt.JwtServiceConfig `yaml:"services"` } + AuthToken jwt.AuthTokensConfig `yaml:"authtoken"` + Languages struct { Enabled bool Path string @@ -1005,7 +1007,7 @@ func (config *Config) processExtjwt() (err error) { // first process the default service, which may be disabled err = config.Extjwt.Default.Postprocess() if err != nil { - return + return fmt.Errorf("invalid extjwt config for default service: %w", err) } // now process the named services. it is an error if any is disabled // also, normalize the service names to lowercase @@ -1013,7 +1015,7 @@ func (config *Config) processExtjwt() (err error) { for service, sConf := range config.Extjwt.Services { err := sConf.Postprocess() if err != nil { - return err + return fmt.Errorf("invalid extjwt config for service %s: %w", service, err) } if !sConf.Enabled() { return fmt.Errorf("no keys enabled for extjwt service %s", service) @@ -1800,6 +1802,13 @@ func LoadConfig(filename string) (config *Config, err error) { return nil, err } + if err = config.AuthToken.Postprocess(); err != nil { + return nil, err + } + if !config.AuthToken.Enabled { + config.Server.supportedCaps.Disable(caps.AuthToken) + } + if config.WebPush.Enabled { if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled { return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled") @@ -1941,6 +1950,10 @@ func (config *Config) generateISupport() (err error) { isupport.Add("draft/ACCOUNTREQUIRED", "") } + if config.AuthToken.Enabled { + isupport.Add(caps.AuthToken005, "") + } + for key, value := range config.Server.AdditionalISupport { if !isupport.Contains(key) { isupport.Add(key, value) diff --git a/irc/handlers.go b/irc/handlers.go index bcfe693a..74777dc7 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -547,12 +547,41 @@ func dispatchAwayNotify(client *Client, awayMessage string) { // BATCH {+,-}reference-tag type [params...] func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + tag := msg.Params[0] + if len(tag) != 0 { + switch tag[0] { + case '+': + // can't open a new C2S batch with one already open, even of a different type + if rb.session.multilineBatch.label == "" && rb.session.tokenValidateBatch == nil { + if len(msg.Params) >= 2 { + switch msg.Params[1] { + case caps.MultilineBatchType: + return batchHandlerMultiline(server, client, msg, rb) + case caps.AuthTokenBatchType: + return batchHandlerTokenStart(server, client, msg, rb) + } + } + } + case '-': + if rb.session.multilineBatch.label != "" { + return batchHandlerMultiline(server, client, msg, rb) + } else if rb.session.tokenValidateBatch != nil { + return batchHandlerTokenEnd(server, client, msg, rb) + } + } + } + failBatch(server, rb) + // reset any local state + rb.session.EndMultilineBatch("") + rb.session.tokenValidateBatch = nil + return false +} + +func batchHandlerMultiline(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { tag := msg.Params[0] fail := false - sendErrors := rb.session.batch.command != "NOTICE" - if len(tag) == 0 { - fail = true - } else if tag[0] == '+' { + sendErrors := rb.session.multilineBatch.command != "NOTICE" + if tag[0] == '+' { if len(msg.Params) < 3 || msg.Params[1] != caps.MultilineBatchType { fail = true } else { @@ -589,6 +618,49 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon return false } +func batchHandlerTokenStart(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + if rb.session.tokenValidateBatch == nil { + if !tokenValidateCheckPermissions(server, server.Config(), client, rb) { + return false + } + if len(msg.Params) < 3 { + failBatch(server, rb) + return false + } + rb.session.tokenValidateBatch = &TokenValidateBatch{ + label: msg.Params[0][1:], + responseLabel: rb.Label, + service: msg.Params[2], + } + rb.Label = "" // suppress ACK for initial BATCH line + } else { + rb.session.tokenValidateBatch = nil + failBatch(server, rb) + } + return false +} + +func batchHandlerTokenEnd(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + tokenValidateBatch := rb.session.tokenValidateBatch + rb.session.tokenValidateBatch = nil + + if tokenValidateBatch == nil { + return failBatch(server, rb) + } + if tokenValidateBatch.label != msg.Params[0][1:] { + return failBatch(server, rb) + } + + rb.Label = tokenValidateBatch.responseLabel + performTokenValidate(server, server.Config(), client, tokenValidateBatch.service, tokenValidateBatch.buf.String(), rb) + return false +} + +func failBatch(server *Server, rb *ResponseBuffer) bool { + rb.Add(nil, server.name, "FAIL", "BATCH", "INVALID_PARAMS", "Corrupt BATCH") + return false +} + // CAP [] func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { details := client.Details() @@ -1190,7 +1262,7 @@ func extjwtHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo return false } - tokenString, err := sConfig.Sign(claims) + tokenString, err := sConfig.SignEXTJWT(claims) if err == nil { maxTokenLength := maxLastArgLength @@ -2261,32 +2333,32 @@ func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, ba } }() - if batchTag != rb.session.batch.label { + if batchTag != rb.session.multilineBatch.label { failParams = []string{"MULTILINE_INVALID", client.t("Incorrect batch tag sent")} return } else if len(msg.Params) < 2 { failParams = []string{"MULTILINE_INVALID", client.t("Invalid multiline batch")} return } - rb.session.batch.command = msg.Command + rb.session.multilineBatch.command = msg.Command isConcat, _ := msg.GetTag(caps.MultilineConcatTag) if isConcat && len(msg.Params[1]) == 0 { failParams = []string{"MULTILINE_INVALID", client.t("Cannot send a blank line with the multiline concat tag")} return } - if !isConcat && len(rb.session.batch.message.Split) != 0 { - rb.session.batch.lenBytes++ // bill for the newline + if !isConcat && len(rb.session.multilineBatch.message.Split) != 0 { + rb.session.multilineBatch.lenBytes++ // bill for the newline } - rb.session.batch.message.Append(msg.Params[1], isConcat) - rb.session.batch.lenBytes += len(msg.Params[1]) + rb.session.multilineBatch.message.Append(msg.Params[1], isConcat) + rb.session.multilineBatch.lenBytes += len(msg.Params[1]) config := server.Config() - if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes { + if config.Limits.Multiline.MaxBytes < rb.session.multilineBatch.lenBytes { failParams = []string{ "MULTILINE_MAX_BYTES", strconv.Itoa(config.Limits.Multiline.MaxBytes), fmt.Sprintf(client.t("Multiline batch byte limit %d exceeded"), config.Limits.Multiline.MaxBytes), } - } else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() { + } else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.multilineBatch.message.LenLines() { failParams = []string{ "MULTILINE_MAX_LINES", strconv.Itoa(config.Limits.Multiline.MaxLines), @@ -3730,6 +3802,217 @@ func timeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons return false } +// TOKEN +func tokenHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + config := server.Config() + if !config.AuthToken.Enabled { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("TOKEN is disabled")) + return false + } + + switch strings.ToUpper(msg.Params[0]) { + case "SERVICELIST": + if !client.registered { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("You must complete connection registration to list services")) + return false + } + tokenServicelistHandler(server, config, client, rb) + case "GENERATE": + if !client.registered { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("You must complete connection registration to issue a token")) + return false + } + tokenGenerateHandler(server, config, client, msg, rb) + case "VALIDATE": + tokenValidateHandler(server, config, client, msg, rb) + default: + rb.Add(nil, server.name, "FAIL", "TOKEN", "UNKNOWN_COMMAND", utils.SafeErrorParam(msg.Params[0]), client.t("Unknown subcommand")) + } + return false +} + +func tokenServicelistHandler(server *Server, config *Config, client *Client, rb *ResponseBuffer) { + if len(config.AuthToken.Services) == 0 { + rb.Add(nil, server.name, "NOTE", "TOKEN", "NO_SERVICES", client.t("No services are defined for this network")) + return + } + batchID := rb.StartNestedBatch(nil, caps.AuthTokenBatchType, "*") + defer rb.EndNestedBatch(batchID) + for srv, conf := range config.AuthToken.Services { + rb.Add(nil, server.name, "TOKEN", "SERVICE", srv, conf.URL, conf.Description) + } +} + +func tokenGenerateHandler(server *Server, config *Config, client *Client, msg ircmsg.Message, rb *ResponseBuffer) { + if !rb.session.capabilities.Has(caps.Batch) { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NEED_CAPABILITY", "batch", client.t("TOKEN GENERATE requires the batch capability")) + return + } + if len(msg.Params) < 2 { + rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_PARAMS", "GENERATE", client.t("Service is a required argument to TOKEN GENERATE")) + return + } + service := strings.ToUpper(msg.Params[1]) + var scope string + if len(msg.Params) > 2 { + scope = msg.Params[2] + } + details := client.Details() + if details.account == "" { + rb.Add(nil, server.name, "FAIL", "TOKEN", "ACCOUNT_REQUIRED", client.t("You must be logged into an account to issue a token")) + return + } + if details.nick != details.accountName { + // [evil laugh] + rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", client.t("You must use your account name as your nickname to issue a token")) + return + } + claims := jwt.AuthToken{ + ServerName: server.name, + Service: service, + Scope: scope, + AccountName: details.accountName, + } + if channel := server.channels.Get(scope); channel != nil { + if m := channel.HighestUserMode(client); m != 0 { + claims.ChannelMode = string(m) + } + } + token, err := config.AuthToken.Issue(claims) + if err != nil { + switch err { + case jwt.ErrNoService: + rb.Add(nil, server.name, "FAIL", "TOKEN", "UNKNOWN_SERVICE", utils.SafeErrorParam(service), client.t("Unknown service")) + default: + // unexpected + server.logger.Error("internal", "failed to issue AUTHTOKEN", err.Error()) + rb.Add(nil, server.name, "FAIL", "TOKEN", "INTERNAL_ERROR", client.t("An error occurred")) + } + return + } + + const tokenChunkLength = 400 + // always send a batch; if we don't we have to try and fit service name + // and the entire token on the same line + batchID := rb.StartNestedBatch(nil, caps.AuthTokenBatchType, service) + defer rb.EndNestedBatch(batchID) + for i := 0; i < len(token); i += tokenChunkLength { + end := min(len(token), i+tokenChunkLength) + chunk := token[i:end] + rb.Add(nil, "", "TOKEN", "GENERATE", "*", chunk) + } +} + +// tokenValidateCheckPermissions is the check to allow a client to validate, +// or start validating, an authtoken. we may eventually add an optional +// PASS requirement or similar. +func tokenValidateCheckPermissions(server *Server, config *Config, client *Client, rb *ResponseBuffer) bool { + if !config.AuthToken.Enabled { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("TOKEN is disabled")) + return false + } + if !config.AuthToken.AllowIP(client.IP()) { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NO_PERMISSIONS", "*", client.t("Your IP address is not allowed to validate auth tokens")) + return false + } + return true +} + +func tokenValidateHandler(server *Server, config *Config, client *Client, msg ircmsg.Message, rb *ResponseBuffer) { + if !rb.session.capabilities.Has(caps.Batch) { + rb.Add(nil, server.name, "FAIL", "TOKEN", "NEED_CAPABILITY", "batch", client.t("TOKEN VALIDATE requires the batch capability")) + return + } + + // batch case, one parameter per TOKEN VALIDATE line (the token chunk) + if present, batchLabel := msg.GetTag("batch"); present { + if len(msg.Params) < 2 { + rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_PARAMS", "VALIDATE", client.t("Insufficient parameters")) + return + } + tokenChunk := msg.Params[1] + if rb.session.tokenValidateBatch != nil && rb.session.tokenValidateBatch.label == batchLabel { + newLen := rb.session.tokenValidateBatch.buf.Len() + len(tokenChunk) + if newLen <= jwt.MaxAuthTokenLength { + // success, absorb into batch and wait for batch end + rb.session.tokenValidateBatch.buf.WriteString(tokenChunk) + } else { + rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_TOKEN", client.t("Token exceeds maximum allowable length")) + rb.session.tokenValidateBatch = nil + } + } + return + } + + // single command case, 3 parameters per TOKEN VALIDATE line (service, URL, token chunk) + if !tokenValidateCheckPermissions(server, config, client, rb) { + return + } + + if len(msg.Params) < 3 { + rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_PARAMS", "VALIDATE", client.t("Insufficient parameters")) + return + } + + performTokenValidate(server, server.Config(), client, msg.Params[1], msg.Params[2], rb) +} + +func performTokenValidate(server *Server, config *Config, client *Client, service, token string, rb *ResponseBuffer) { + service = strings.ToUpper(service) + claims, err := config.AuthToken.Verify(service, token) + if err != nil { + rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_TOKEN", client.t("Invalid token")) + return + } + + subject := server.clients.Get(claims.AccountName) + if subject == nil || subject.AccountName() != claims.AccountName { + // the original issuing client is offline, or "nick equals account" is disabled + // in one of several possible ways (force-nick-equals-account is off, or even + // strict nickname reservation is off), in which case we are going to refuse + // to validate any claims + rb.Add(nil, server.name, "FAIL", "TOKEN", "INVALID_TOKEN", client.t("Could not verify user presence")) + return + } + + batchID := rb.StartNestedBatch(nil, caps.AuthTokenBatchType, service) + defer rb.EndNestedBatch(batchID) + + rb.Add(nil, server.name, "TOKEN", "CLAIM", "name", claims.AccountName) + rb.Add(nil, server.name, "TOKEN", "CLAIM", "account", claims.AccountName) + if claims.Scope != "" { + rb.Add(nil, server.name, "TOKEN", "CLAIM", "scope", claims.Scope) + } + + var memberOf, operatorOf utils.TokenLineBuilder + memberOf.Initialize(300, " ") + operatorOf.Initialize(300, " ") + + for _, channel := range subject.Channels() { + chname := channel.Name() + memberOf.Add(chname) + if channel.ClientIsAtLeast(subject, modes.ChannelOperator) { + operatorOf.Add(chname) + } + } + + playMultilineClaim := func(claim string, lines []string) { + for i, line := range lines { + if i != 0 { + // "The server produces a leading space in the second line of + // the member_of claim because the client must concatenate the lines + // together with no separators." + line = " " + line + } + rb.Add(nil, server.name, "TOKEN", "CLAIM", claim, line) + } + } + playMultilineClaim("member_of", memberOf.Lines()) + playMultilineClaim("operator_of", operatorOf.Lines()) + + return +} + // TOPIC [] func topicHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { channel := server.channels.Get(msg.Params[0]) diff --git a/irc/help.go b/irc/help.go index ee1fb762..0443329c 100644 --- a/irc/help.go +++ b/irc/help.go @@ -532,6 +532,12 @@ Reloads the config file and updates TLS certificates on listeners`, text: `TIME [server] Shows the time of the current, or the given, server.`, + }, + "token": { + text: `TOKEN [args] + +TOKEN issues and validates tokens for use by external services. +It is not intended for direct use by end users.`, }, "topic": { text: `TOPIC [topic] diff --git a/irc/jwt/authtoken.go b/irc/jwt/authtoken.go new file mode 100644 index 00000000..a10a184a --- /dev/null +++ b/irc/jwt/authtoken.go @@ -0,0 +1,184 @@ +package jwt + +import ( + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/ergochat/ergo/irc/utils" + + jwt "github.com/golang-jwt/jwt/v5" +) + +const ( + MaxAuthTokenLength = 2048 // TODO check this +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrNoService = errors.New("invalid authtoken service") + + parser = jwt.NewParser(jwt.WithExpirationRequired()) +) + +type AuthTokensConfig struct { + Enabled bool + VerificationIPWhitelist []string `yaml:"verification-ip-whitelist"` + verificationIPWhitelist []net.IPNet + + Services map[string]JwtServiceConfig +} + +// AuthToken is the internal representation of an auth token's data, +// implemented as a stateless signed JWT. +type AuthToken struct { + ServerName string + Service string + URL string + AccountName string + Scope string + ChannelMode string +} + +func (t *AuthTokensConfig) Postprocess() (err error) { + if !t.Enabled { + t.Services = nil // simplify diffing later + return nil + } + + t.verificationIPWhitelist, err = utils.ParseNetList(t.VerificationIPWhitelist) + if err != nil { + return err + } + + services := make(map[string]JwtServiceConfig, len(t.Services)) + for srv, conf := range t.Services { + if err := conf.Postprocess(); err != nil { + return fmt.Errorf("TOKEN service %s is misconfigured: %w", srv, err) + } + if !conf.Enabled() { + return fmt.Errorf("TOKEN service %s lacks necessary configuration", srv) + } + if conf.URL == "" { + return fmt.Errorf("TOKEN service %s lacks a URL", srv) + } + services[strings.ToUpper(srv)] = conf + } + t.Services = services + return nil +} + +func (oldConf *AuthTokensConfig) GetDifference(newConf AuthTokensConfig) (result [][]string) { + for srv := range oldConf.Services { + if _, ok := newConf.Services[srv]; !ok { + result = append(result, []string{"DEL", srv}) + } + } + + for srv, conf := range newConf.Services { + if oldConf, ok := oldConf.Services[srv]; !ok || conf.URL != oldConf.URL { + result = append(result, []string{"NEW", srv, conf.URL}) + } + } + + return +} + +func (t *AuthTokensConfig) getService(service string) (result JwtServiceConfig, err error) { + if !t.Enabled { + err = ErrNoService + return + } + + result, ok := t.Services[service] + if !ok || !result.Enabled() { + err = ErrNoService + return + } + + return result, nil +} + +func (t *AuthTokensConfig) AllowIP(ip net.IP) bool { + return utils.IPInNets(ip, t.verificationIPWhitelist) +} + +func (t *AuthTokensConfig) Issue(token AuthToken) (result string, err error) { + service := strings.ToUpper(token.Service) + conf, err := t.getService(service) + if err != nil { + return + } + + claims := make(jwt.MapClaims) + // standard claims: + claims["iss"] = token.ServerName + claims["exp"] = time.Now().Unix() + int64(conf.Expiration/time.Second) + claims["aud"] = conf.URL + // ergo-specific claims + claims["srv"] = service + claims["acc"] = token.AccountName + if token.Scope != "" { + claims["scope"] = token.Scope + } + if token.ChannelMode != "" { + claims["chmode"] = token.ChannelMode + } + // TODO include operclass if available? + + j := jwt.NewWithClaims(conf.signingMethod, jwt.MapClaims(claims)) + return j.SignedString(conf.signingKey) +} + +func (t *AuthTokensConfig) Verify(service, token string) (result AuthToken, err error) { + service = strings.ToUpper(service) + conf, err := t.getService(service) + if err != nil { + return + } + // since we looked up the service, we now know the correct signing key + tok, err := parser.Parse(token, conf.verifyKeyFunc) + if err != nil { + return + } + + // validate the exact signing method just in case (although it should be impossible + // to, e.g. validate a HS256 token with a *rsa.PrivateKey signing key) + if tok.Method != conf.signingMethod { + err = ErrInvalidToken + return + } + + mc := tok.Claims.(jwt.MapClaims) + + srvClaim := extractStringClaim(mc, "srv") + if service != srvClaim { + err = ErrInvalidToken + return + } + audClaim := extractStringClaim(mc, "aud") + if conf.URL != audClaim { + err = ErrInvalidToken + return + } + + return AuthToken{ + // don't care about iss / ServerName + Service: srvClaim, + URL: audClaim, + AccountName: extractStringClaim(mc, "acc"), + Scope: extractStringClaim(mc, "scope"), + // don't return channel mode, revalidate it from runtime data + }, nil +} + +func extractStringClaim(claims jwt.MapClaims, key string) string { + if result, ok := claims[key]; ok { + if strResult, ok := result.(string); ok { + return strResult + } + } + return "" +} diff --git a/irc/jwt/authtoken_test.go b/irc/jwt/authtoken_test.go new file mode 100644 index 00000000..7a1e1ed8 --- /dev/null +++ b/irc/jwt/authtoken_test.go @@ -0,0 +1,119 @@ +package jwt + +import ( + "reflect" + "testing" + "time" +) + +func TestAuthTokenRoundTrip(t *testing.T) { + conf := AuthTokensConfig{ + Enabled: true, + Services: map[string]JwtServiceConfig{ + "FILEHOST": { + Expiration: 10 * time.Minute, + URL: "https://example.com", + Algorithm: "rsa", + KeyString: rsaTestPrivKey, + }, + }, + } + + err := conf.Postprocess() + if err != nil { + t.Fatalf("couldn't parse config: %v", err) + } + + tok := AuthToken{ + ServerName: "irc.ergo.chat", + Service: "FILEHOST", + AccountName: "slingamn", + Scope: "#ergo", + ChannelMode: "o", + } + + jtok, err := conf.Issue(tok) + if err != nil { + t.Fatalf("couldn't issue token: %v", err) + } + + result, err := conf.Verify("FILEHOST", jtok) + if err != nil { + t.Errorf("couldn't validate token: %v", err) + } + + if result.AccountName != "slingamn" || result.Scope != "#ergo" { + t.Errorf("didn't recover required fields from token: %#v", result) + } + + _, err = conf.Verify("FILEHOST", jtok[:len(jtok)-1]) + if err == nil { + t.Errorf("validated token with bad signature") + } +} + +func TestAuthTokenDiff(t *testing.T) { + oldConf := AuthTokensConfig{ + Enabled: true, + Services: map[string]JwtServiceConfig{ + "FILEHOST": { + Expiration: 10 * time.Minute, + URL: "https://example.com/filehost", + Algorithm: "rsa", + KeyString: rsaTestPrivKey, + }, + "QDB": { + Expiration: 10 * time.Minute, + URL: "https://example.com/qdb", + Algorithm: "hmac", + KeyString: "MbKjh6CTqLMPZV9XLYmACw", + }, + "JITSI": { + Expiration: 10 * time.Minute, + URL: "https://example.com/jitsi", + Algorithm: "hmac", + KeyString: "uaKzJTbuqjHlbGrvwku2kw", + }, + }, + } + + err := oldConf.Postprocess() + if err != nil { + t.Fatalf("couldn't parse config: %v", err) + } + + newConf := AuthTokensConfig{ + Enabled: true, + Services: map[string]JwtServiceConfig{ + // change the filehost URL + "FILEHOST": { + Expiration: 10 * time.Minute, + URL: "https://filehost.com/filehost", + Algorithm: "rsa", + KeyString: rsaTestPrivKey, + }, + // QDB is deleted + // jitsi is at the same URL with a different key + "JITSI": { + Expiration: 10 * time.Minute, + URL: "https://example.com/jitsi", + Algorithm: "rsa", + KeyString: rsaTestPrivKey, + }, + }, + } + + err = newConf.Postprocess() + if err != nil { + t.Fatalf("couldn't parse config: %v", err) + } + + expectedDiff := [][]string{ + {"DEL", "QDB"}, + {"NEW", "FILEHOST", "https://filehost.com/filehost"}, + } + diff := oldConf.GetDifference(newConf) + if !reflect.DeepEqual(diff, expectedDiff) { + t.Fatalf("incorrect diff: %#v", diff) + } +} diff --git a/irc/jwt/bearer.go b/irc/jwt/bearer.go index 4526ae8a..e14e9774 100644 --- a/irc/jwt/bearer.go +++ b/irc/jwt/bearer.go @@ -5,26 +5,27 @@ package jwt import ( "fmt" - "io" "os" "strings" + "github.com/ergochat/ergo/irc/utils" jwt "github.com/golang-jwt/jwt/v5" ) var ( ErrAuthDisabled = fmt.Errorf("JWT authentication is disabled") ErrNoValidAccountClaim = fmt.Errorf("JWT token did not contain an acceptable account name claim") + ErrNoValidAudClaim = fmt.Errorf("JWT token did not contain an acceptable aud claim") ) // JWTAuthConfig is the config for Ergo to accept JWTs via draft/bearer type JWTAuthConfig struct { - Enabled bool `yaml:"enabled"` - Autocreate bool `yaml:"autocreate"` - Tokens []JWTAuthTokenConfig `yaml:"tokens"` + Enabled bool `yaml:"enabled"` + Autocreate bool `yaml:"autocreate"` + Tokens []JWTBearerTokenConfig `yaml:"tokens"` } -type JWTAuthTokenConfig struct { +type JWTBearerTokenConfig struct { Algorithm string `yaml:"algorithm"` KeyString string `yaml:"key"` KeyFile string `yaml:"key-file"` @@ -32,6 +33,8 @@ type JWTAuthTokenConfig struct { parser *jwt.Parser AccountClaims []string `yaml:"account-claims"` StripDomain string `yaml:"strip-domain"` + ValidateAud []string `yaml:"validate-aud"` + allowedAuds utils.HashSet[string] } func (j *JWTAuthConfig) Postprocess() error { @@ -52,7 +55,7 @@ func (j *JWTAuthConfig) Postprocess() error { return nil } -func (j *JWTAuthTokenConfig) Postprocess() error { +func (j *JWTBearerTokenConfig) Postprocess() error { keyBytes, err := j.keyBytes() if err != nil { return err @@ -82,13 +85,18 @@ func (j *JWTAuthTokenConfig) Postprocess() error { default: return fmt.Errorf("invalid jwt algorithm: %s", j.Algorithm) } - j.parser = jwt.NewParser(jwt.WithValidMethods(methods)) + j.parser = jwt.NewParser(jwt.WithValidMethods(methods), jwt.WithExpirationRequired()) if len(j.AccountClaims) == 0 { return fmt.Errorf("JWT auth enabled, but no account-claims specified") } j.StripDomain = strings.ToLower(j.StripDomain) + + if len(j.ValidateAud) != 0 { + j.allowedAuds = utils.SetLiteral(j.ValidateAud...) + } + return nil } @@ -106,14 +114,9 @@ func (j *JWTAuthConfig) Validate(t string) (accountName string, err error) { return } -func (j *JWTAuthTokenConfig) keyBytes() (result []byte, err error) { +func (j *JWTBearerTokenConfig) keyBytes() (result []byte, err error) { if j.KeyFile != "" { - o, err := os.Open(j.KeyFile) - if err != nil { - return nil, err - } - defer o.Close() - return io.ReadAll(o) + return os.ReadFile(j.KeyFile) } if j.KeyString != "" { return []byte(j.KeyString), nil @@ -122,11 +125,11 @@ func (j *JWTAuthTokenConfig) keyBytes() (result []byte, err error) { } // implements jwt.Keyfunc -func (j *JWTAuthTokenConfig) keyFunc(_ *jwt.Token) (interface{}, error) { +func (j *JWTBearerTokenConfig) keyFunc(_ *jwt.Token) (interface{}, error) { return j.key, nil } -func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error) { +func (j *JWTBearerTokenConfig) Validate(t string) (accountName string, err error) { token, err := j.parser.Parse(t, j.keyFunc) if err != nil { return "", err @@ -138,6 +141,10 @@ func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error) return "", fmt.Errorf("unexpected type from parsed token claims: %T", claims) } + if !j.validateAudClaim(claims) { + return "", ErrNoValidAudClaim + } + for _, c := range j.AccountClaims { if v, ok := claims[c]; ok { if vstr, ok := v.(string); ok { @@ -156,3 +163,37 @@ func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error) return "", ErrNoValidAccountClaim } + +func (j *JWTBearerTokenConfig) validateAudClaim(claims jwt.MapClaims) bool { + if j.allowedAuds == nil { + return true // no validate-aud means any aud is allowed + } + + audClaim, ok := claims["aud"] + if !ok { + return false + } + + switch aud := audClaim.(type) { + case string: + return j.allowedAuds.Has(aud) + case []any: + for _, a := range aud { + if aStr, ok := a.(string); ok { + if j.allowedAuds.Has(aStr) { + return true + } + } + } + return false + case []string: + for _, a := range aud { + if j.allowedAuds.Has(a) { + return true + } + } + return false + default: + return false + } +} diff --git a/irc/jwt/bearer_test.go b/irc/jwt/bearer_test.go index 3d4cd406..64bf31c4 100644 --- a/irc/jwt/bearer_test.go +++ b/irc/jwt/bearer_test.go @@ -2,6 +2,7 @@ package jwt import ( "testing" + "time" jwt "github.com/golang-jwt/jwt/v5" ) @@ -50,11 +51,11 @@ s/uzBKNwWf9UPTeIt+4JScg= func TestJWTBearerAuth(t *testing.T) { j := JWTAuthConfig{ Enabled: true, - Tokens: []JWTAuthTokenConfig{ + Tokens: []JWTBearerTokenConfig{ { Algorithm: "rsa", KeyString: rsaTestPubKey, - AccountClaims: []string{"preferred_username", "email"}, + AccountClaims: []string{"preferred_username", "email", "account"}, StripDomain: "example.com", }, }, @@ -65,7 +66,7 @@ func TestJWTBearerAuth(t *testing.T) { } // fixed test vector signed with the RSA privkey: - token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzbGluZ2FtbiJ9.caPZw2Dl4KZN-SErD5-WZB_lPPveHXaMCoUHxNebb94G9w3VaWDIRdngVU99JKx5nE_yRtpewkHHvXsQnNA_M63GBXGK7afXB8e-kV33QF3v9pXALMP5SzRwMgokyxas0RgHu4e4L0d7dn9o_nkdXp34GX3Pn1MVkUGBH6GdlbOdDHrs04pPQ0Qj-O2U0AIpnZq-X_GQs9ECJo4TlPKWR7Jlq5l9bS0dBnohea4FuqJr232je-dlRVkbCa7nrnFmsIsezsgA3Jb_j9Zu_iv460t_d2eaytbVp9P-DOVfzUfkBsKs-81URQEnTjW6ut445AJz2pxjX92X0GdmORpAkQ" + token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50Ijoic2xpbmdhbW4iLCJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tL2ZpbGVob3N0IiwiZXhwIjo4MDgzODY1NDkyLCJpc3MiOiJlcmdvLnRlc3QiLCJzcnYiOiJGSUxFSE9TVCJ9.d_tMt4UWuuq3KDgKF4wCyL0tKaeKTCqrKgFZdogOetqmp9qVxi05sMlXawmheWAf3cjQG1ZxCvoc0TovI8H5d5DsVW5txNAXEhYlFKp8Vbd86J04VH2fn32brv5BH9oMPu60bnaEyv_vkKuFMANJzNgQOlMbNTo1IBKYmppi0dVbaBPtylMfL2jTQBwNj6m2_Bv_7N3tf9IgTIRX-Z2VbniHjTB9sEZaFgk6mxj-kwjxqu-lTAxmsPy4H5CBQb-Ea47LBFPmoLt6caxA4VCZyDq1chxcU5DLtv8ec9Sk1XvrGlyWtZ6pD9rT93jpSN6e5r5ceirkvgh20sUIWOOsHg" accountName, err := j.Validate(token) if err != nil { t.Errorf("could not validate valid token: %v", err) @@ -79,11 +80,8 @@ func TestJWTBearerAuth(t *testing.T) { if err != nil { t.Fatal(err) } - jTok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"})) - token, err = jTok.SignedString(privKey) - if err != nil { - t.Fatal(err) - } + exp := time.Now().Add(time.Hour).Unix() + token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": exp})) accountName, err = j.Validate(token) if err != nil { t.Errorf("could not validate valid token: %v", err) @@ -93,46 +91,28 @@ func TestJWTBearerAuth(t *testing.T) { } // test expiration - jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": 1675740865})) - token, err = jTok.SignedString(privKey) - if err != nil { - t.Fatal(err) - } + token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": 1675740865})) accountName, err = j.Validate(token) if err == nil { t.Errorf("validated expired token") } // test for the infamous algorithm confusion bug - jTok = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"})) - token, err = jTok.SignedString([]byte(rsaTestPubKey)) - if err != nil { - t.Fatal(err) - } + token = signTokenForTesting(jwt.SigningMethodHS256, []byte(rsaTestPubKey), jwt.MapClaims(map[string]any{"preferred_username": "slingamn"})) accountName, err = j.Validate(token) if err == nil { t.Errorf("validated HS256 token despite RSA being required") } // test no valid claims - jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"sub": "slingamn"})) - token, err = jTok.SignedString(privKey) - if err != nil { - t.Fatal(err) - } - + token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"sub": "slingamn", "exp": exp})) accountName, err = j.Validate(token) if err != ErrNoValidAccountClaim { t.Errorf("expected ErrNoValidAccountClaim, got: %v", err) } // test email addresses - jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"email": "Slingamn@example.com"})) - token, err = jTok.SignedString(privKey) - if err != nil { - t.Fatal(err) - } - + token = signTokenForTesting(jwt.SigningMethodRS256, privKey, jwt.MapClaims(map[string]any{"email": "Slingamn@example.com", "exp": exp})) accountName, err = j.Validate(token) if err != nil { t.Errorf("could not validate valid token: %v", err) @@ -141,3 +121,84 @@ func TestJWTBearerAuth(t *testing.T) { t.Errorf("incorrect account name for token: `%s`", accountName) } } + +func signTokenForTesting(method jwt.SigningMethod, key any, claims jwt.MapClaims) (token string) { + jTok := jwt.NewWithClaims(method, claims) + token, err := jTok.SignedString(key) + if err != nil { + panic(err) + } + return token +} + +func TestJWTBearerAudValidation(t *testing.T) { + key := []byte("MowTTyXKkN58DG2uNMsoCgAa6CM6ElFlcq_7Ocl6wsU") + j := JWTAuthConfig{ + Enabled: true, + Tokens: []JWTBearerTokenConfig{ + { + Algorithm: "hmac", + KeyString: string(key), + AccountClaims: []string{"account"}, + ValidateAud: []string{"irc.ergo.chat", "https://irc.ergo.chat"}, + }, + }, + } + + if err := j.Postprocess(); err != nil { + t.Fatal(err) + } + + exp := time.Now().Add(time.Hour).Unix() + + token := signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{"account": "slingamn", "exp": exp})) + if _, err := j.Validate(token); err == nil { + t.Errorf("validated token with missing aud") + } + + token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{"account": "slingamn", "exp": exp, "aud": "irc.ergo.chat"})) + if _, err := j.Validate(token); err != nil { + t.Errorf("failed to validate token with string aud: %v", err) + } + + token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{"account": "slingamn", "exp": exp, "aud": "ergo.chat"})) + if _, err := j.Validate(token); err == nil { + t.Errorf("validated token with invalid string aud") + } + + token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{ + "account": "slingamn", + "exp": exp, + "aud": []string{"https://example.com", "irc.ergo.chat"}, + })) + if _, err := j.Validate(token); err != nil { + t.Errorf("failed to validate token with list aud: %v", err) + } + + token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{ + "account": "slingamn", + "exp": exp, + "aud": []string{"https://example.com", "ergo.chat"}, + })) + if _, err := j.Validate(token); err == nil { + t.Errorf("validated token with invalid list aud") + } + + token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{ + "account": "slingamn", + "exp": exp, + "aud": make([]string, 0), + })) + if _, err := j.Validate(token); err == nil { + t.Errorf("validated token with invalid list aud") + } + + token = signTokenForTesting(jwt.SigningMethodHS256, key, jwt.MapClaims(map[string]any{ + "account": "slingamn", + "exp": exp, + "aud": []int{1, 2}, + })) + if _, err := j.Validate(token); err == nil { + t.Errorf("validated token with invalid list aud") + } +} diff --git a/irc/jwt/extjwt.go b/irc/jwt/extjwt.go index ea764667..6cf18ad8 100644 --- a/irc/jwt/extjwt.go +++ b/irc/jwt/extjwt.go @@ -5,9 +5,11 @@ package jwt import ( - "crypto/rsa" + "crypto/ed25519" "errors" + "fmt" "os" + "strings" "time" jwt "github.com/golang-jwt/jwt/v5" @@ -20,43 +22,84 @@ var ( type MapClaims jwt.MapClaims type JwtServiceConfig struct { - Expiration time.Duration - Secret string - secretBytes []byte - RSAPrivateKeyFile string `yaml:"rsa-private-key-file"` - rsaPrivateKey *rsa.PrivateKey + Expiration time.Duration + Description string + URL string `yaml:"url"` + Algorithm string `yaml:"algorithm"` + KeyString string `yaml:"key"` + KeyFile string `yaml:"key-file"` + signingMethod jwt.SigningMethod + signingKey any + verifyKey any } func (t *JwtServiceConfig) Postprocess() (err error) { - t.secretBytes = []byte(t.Secret) - t.Secret = "" - if t.RSAPrivateKeyFile != "" { - keyBytes, err := os.ReadFile(t.RSAPrivateKeyFile) - if err != nil { - return err - } - t.rsaPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes) - if err != nil { - return err - } + if t.Algorithm == "" { + // disabled + return } + + var keyBytes []byte + if t.KeyFile != "" { + keyBytes, err = os.ReadFile(t.KeyFile) + if err != nil { + return + } + } else if t.KeyString != "" { + keyBytes = []byte(t.KeyString) + } else { + return ErrNoKeys + } + + switch strings.ToLower(t.Algorithm) { + case "hmac": + t.signingKey = keyBytes + t.verifyKey = keyBytes + t.signingMethod = jwt.SigningMethodHS256 + case "rsa": + rsaPrivkey, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + if err != nil { + return err + } + t.signingKey = rsaPrivkey + t.verifyKey = rsaPrivkey.Public() + t.signingMethod = jwt.SigningMethodRS256 + case "eddsa": + ecPrivkey, err := jwt.ParseEdPrivateKeyFromPEM(keyBytes) + if err != nil { + return err + } + t.signingKey = ecPrivkey + ed25519PrivKey, ok := ecPrivkey.(ed25519.PrivateKey) + if !ok { + // impossible due to golang-jwt enforcement: + return errors.New("unexpected non-ed25519 private key found") + } + t.verifyKey = ed25519PrivKey.Public() + t.signingMethod = jwt.SigningMethodEdDSA + default: + return fmt.Errorf("invalid JWT algorithm: %s", t.Algorithm) + } + return nil } func (t *JwtServiceConfig) Enabled() bool { - return t.Expiration != 0 && (len(t.secretBytes) != 0 || t.rsaPrivateKey != nil) + return t.Expiration != 0 && t.signingMethod != nil } -func (t *JwtServiceConfig) Sign(claims MapClaims) (result string, err error) { +func (t *JwtServiceConfig) verifyKeyFunc(_ *jwt.Token) (key any, err error) { + return t.verifyKey, nil +} + +func (t *JwtServiceConfig) SignEXTJWT(claims MapClaims) (result string, err error) { + if !t.Enabled() { + err = ErrNoKeys + return + } + 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 - } + token := jwt.NewWithClaims(t.signingMethod, jwt.MapClaims(claims)) + return token.SignedString(t.signingKey) } diff --git a/irc/server.go b/irc/server.go index efe23ee5..372d108c 100644 --- a/irc/server.go +++ b/irc/server.go @@ -53,6 +53,8 @@ const ( chanTypes = "#" throttleMessage = "You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect." + + rawIOWarningMessage = "This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect." ) var ( @@ -522,6 +524,9 @@ func (server *Server) playRegistrationBurst(session *Session) { if d.account != "" && session.capabilities.Has(caps.Persistence) { reportPersistenceStatus(c, rb, false) } + if session.capabilities.Has(caps.AuthToken) { + tokenServicelistHandler(server, config, c, rb) + } server.Lusers(c, rb) server.MOTD(c, rb) rb.Send(true) @@ -534,7 +539,7 @@ func (server *Server) playRegistrationBurst(session *Session) { c.attemptAutoOper(session) if server.logger.IsLoggingRawIO() { - session.Send(nil, c.server.name, "NOTICE", d.nick, c.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect.")) + session.Send(nil, c.server.name, "NOTICE", d.nick, c.t(rawIOWarningMessage)) } } @@ -901,8 +906,10 @@ func (server *Server) applyConfig(config *Config) (err error) { // set RPL_ISUPPORT var newISupportReplies [][]string + var authTokenDiff [][]string if oldConfig != nil { newISupportReplies = oldConfig.Server.isupport.GetDifference(&config.Server.isupport) + authTokenDiff = oldConfig.AuthToken.GetDifference(config.AuthToken) } if len(config.Server.ProxyAllowedFrom) != 0 { @@ -917,21 +924,25 @@ func (server *Server) applyConfig(config *Config) (err error) { sdnotify.Ready() } - if !initial { - // send 005 updates (somewhat rare) - if len(newISupportReplies) != 0 { - for _, sClient := range server.clients.AllClients() { - for _, session := range sClient.Sessions() { - rb := NewResponseBuffer(session) + if !initial && (len(newISupportReplies) > 0 || len(authTokenDiff) > 0) { + for _, sClient := range server.clients.AllClients() { + for _, session := range sClient.Sessions() { + rb := NewResponseBuffer(session) + if len(newISupportReplies) > 0 { server.sendRplISupportLines(sClient, rb, newISupportReplies) - rb.Send(false) } + if len(authTokenDiff) > 0 && session.capabilities.Has(caps.AuthToken) { + for _, line := range authTokenDiff { + rb.Add(nil, server.name, "TOKEN", line...) + } + } + rb.Send(false) } } if sendRawOutputNotice { for _, sClient := range server.clients.AllClients() { - sClient.Notice(sClient.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect.")) + sClient.Notice(sClient.t(rawIOWarningMessage)) } } } diff --git a/traditional.yaml b/traditional.yaml index d54c0284..ef1e6b72 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -291,6 +291,8 @@ server: # constant list of args to pass to the command; the actual query # and result are transmitted over stdin/stdout: args: [] + # alternatively, pass the input to a persistent process over unix domain socket: + #socket: "/tmp/ergo_ip_check_sidecar" # timeout for process execution, after which we send a SIGTERM: timeout: 9s # how long after the SIGTERM before we follow up with a SIGKILL: @@ -589,6 +591,8 @@ accounts: # constant list of args to pass to the command; the actual authentication # data is transmitted over stdin/stdout: args: [] + # alternatively, pass the input to a persistent process over unix domain socket: + #socket: "/tmp/ergo_auth_sidecar" # should we automatically create users if the plugin returns success? autocreate: true # timeout for process execution, after which we send a SIGTERM: @@ -631,6 +635,9 @@ accounts: # if a claim is formatted as an email address, require it to have the following domain, # and then strip off the domain and use the local-part as the account name: #strip-domain: "example.com" + # optional list of `aud` claims that are acceptable + # (if omitted, the aud claim is not validated): + #validate-aud: ["irc.mydomain.com"] # channel options channels: @@ -994,16 +1001,34 @@ 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" + # algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519) + # # hmac takes a symmetric key, rsa and eddsa take PEM-encoded private keys; + # # either way, the key can be specified either as a YAML string: + # key: "nANiZ1De4v6WnltCHN2H7Q" + # # or as a path to the file containing the key: + # #key-file: "jwt_privkey.pem" # # named services (for `EXTJWT #channel service_name`): # services: # "jitsi": # expiration: 30s - # secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" + # algorithm: "hmac" + # key: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" + +# experimental draft/AUTHTOKEN mechanism +authtoken: + enabled: false + + # only these IPs can verify tokens + verification-ip-whitelist: + - "localhost" + + #services: + # "FILEHOST": + # expiration: 5m + # url: "https://example.com/filehost" + # algorithm: "eddsa" + # key: "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHfpO1x835o9NIQA1kBkN7/Myd6wqE/m/EYJUHBC18hW\n-----END PRIVATE KEY-----" # history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback, # various autoreplay features, and the resume extension