diff --git a/gencapdefs.py b/gencapdefs.py index 9cd667e7..4e619e60 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -15,12 +15,6 @@ from collections import namedtuple CapDef = namedtuple("CapDef", ['identifier', 'name', 'url', 'standard']) CAPDEFS = [ - CapDef( - identifier="Acc", - name="draft/acc", - url="https://github.com/ircv3/ircv3-specifications/pull/276", - standard="proposed IRCv3", - ), CapDef( identifier="AccountNotify", name="account-notify", @@ -163,7 +157,7 @@ CAPDEFS = [ identifier="EventPlayback", name="draft/event-playback", url="https://github.com/ircv3/ircv3-specifications/pull/362", - standard="Proposed IRCv3", + standard="proposed IRCv3", ), CapDef( identifier="ZNCPlayback", @@ -181,7 +175,7 @@ CAPDEFS = [ identifier="Multiline", name="draft/multiline", url="https://github.com/ircv3/ircv3-specifications/pull/398", - standard="Proposed IRCv3", + standard="proposed IRCv3", ), ] diff --git a/irc/accounts.go b/irc/accounts.go index afdf171e..5b5540ed 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -36,6 +36,8 @@ const ( keyVHostQueueAcctToId = "vhostQueue %s" vhostRequestIdx = "vhostQueue" + + maxCertfpsPerAccount = 5 ) // everything about accounts is persistent; therefore, the database is the authoritative @@ -327,7 +329,14 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) certFPKey := fmt.Sprintf(keyCertToAccount, certfp) - credStr, err := am.serializeCredentials(passphrase, certfp) + var creds AccountCredentials + creds.Version = 1 + err = creds.SetPassphrase(passphrase, am.server.Config().Accounts.Registration.BcryptCost) + if err != nil { + return err + } + creds.AddCertfp(certfp) + credStr, err := creds.Serialize() if err != nil { return err } @@ -411,58 +420,124 @@ func validatePassphrase(passphrase string) error { return nil } -// helper to assemble the serialized JSON for an account's credentials -func (am *AccountManager) serializeCredentials(passphrase string, certfp string) (result string, err error) { - var creds AccountCredentials - creds.Version = 1 - // we need at least one of passphrase and certfp: - if passphrase == "" && certfp == "" { - return "", errAccountBadPassphrase - } - // but if we have one, it's fine if the other is missing, it just means no - // credential of that type will be accepted. - creds.Certificate = certfp - if passphrase != "" { - if validatePassphrase(passphrase) != nil { - return "", errAccountBadPassphrase - } - bcryptCost := int(am.server.Config().Accounts.Registration.BcryptCost) - creds.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), bcryptCost) - if err != nil { - am.server.logger.Error("internal", "could not hash password", err.Error()) - return "", errAccountCreation - } - } - - credText, err := json.Marshal(creds) - if err != nil { - am.server.logger.Error("internal", "could not marshal credentials", err.Error()) - return "", errAccountCreation - } - return string(credText), nil -} - // changes the password for an account -func (am *AccountManager) setPassword(account string, password string) (err error) { - casefoldedAccount, err := CasefoldName(account) +func (am *AccountManager) setPassword(account string, password string, hasPrivs bool) (err error) { + cfAccount, err := CasefoldName(account) if err != nil { - return err + return errAccountDoesNotExist } - act, err := am.LoadAccount(casefoldedAccount) + + credKey := fmt.Sprintf(keyAccountCredentials, cfAccount) + var credStr string + am.server.store.View(func(tx *buntdb.Tx) error { + // no need to check verification status here or below; + // you either need to be auth'ed to the account or be an oper to do this + credStr, err = tx.Get(credKey) + return nil + }) + + if err != nil { + return errAccountDoesNotExist + } + + var creds AccountCredentials + err = json.Unmarshal([]byte(credStr), &creds) if err != nil { return err } - credStr, err := am.serializeCredentials(password, act.Credentials.Certificate) + err = creds.SetPassphrase(password, am.server.Config().Accounts.Registration.BcryptCost) if err != nil { return err } - credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) - return am.server.store.Update(func(tx *buntdb.Tx) error { - _, _, err := tx.Set(credentialsKey, credStr, nil) + if creds.Empty() && !hasPrivs { + return errEmptyCredentials + } + + newCredStr, err := creds.Serialize() + if err != nil { + return err + } + + err = am.server.store.Update(func(tx *buntdb.Tx) error { + curCredStr, err := tx.Get(credKey) + if credStr != curCredStr { + return errCASFailed + } + _, _, err = tx.Set(credKey, newCredStr, nil) return err }) + + return err +} + +func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) { + certfp, err = utils.NormalizeCertfp(certfp) + if err != nil { + return err + } + + cfAccount, err := CasefoldName(account) + if err != nil { + return errAccountDoesNotExist + } + + credKey := fmt.Sprintf(keyAccountCredentials, cfAccount) + var credStr string + am.server.store.View(func(tx *buntdb.Tx) error { + credStr, err = tx.Get(credKey) + return nil + }) + + if err != nil { + return errAccountDoesNotExist + } + + var creds AccountCredentials + err = json.Unmarshal([]byte(credStr), &creds) + if err != nil { + return err + } + + if add { + err = creds.AddCertfp(certfp) + } else { + err = creds.RemoveCertfp(certfp) + } + if err != nil { + return err + } + + if creds.Empty() && !hasPrivs { + return errEmptyCredentials + } + + newCredStr, err := creds.Serialize() + if err != nil { + return err + } + + certfpKey := fmt.Sprintf(keyCertToAccount, certfp) + err = am.server.store.Update(func(tx *buntdb.Tx) error { + curCredStr, err := tx.Get(credKey) + if credStr != curCredStr { + return errCASFailed + } + if add { + _, err = tx.Get(certfpKey) + if err != buntdb.ErrNotFound { + return errCertfpAlreadyExists + } + tx.Set(certfpKey, cfAccount, nil) + } else { + tx.Delete(certfpKey) + } + _, _, err = tx.Set(credKey, newCredStr, nil) + return err + }) + + return err } func (am *AccountManager) dispatchCallback(client *Client, casefoldedAccount string, callbackNamespace string, callbackValue string) (string, error) { @@ -574,8 +649,8 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er // XXX we shouldn't do (de)serialization inside the txn, // but this is like 2 usec on my system json.Unmarshal([]byte(raw.Credentials), &creds) - if creds.Certificate != "" { - certFPKey := fmt.Sprintf(keyCertToAccount, creds.Certificate) + for _, cert := range creds.Certfps { + certFPKey := fmt.Sprintf(keyCertToAccount, cert) tx.Set(certFPKey, casefoldedAccount, nil) } @@ -906,14 +981,16 @@ func (am *AccountManager) Unregister(account string) error { if err == nil { var creds AccountCredentials - if err = json.Unmarshal([]byte(credText), &creds); err == nil && creds.Certificate != "" { - certFPKey := fmt.Sprintf(keyCertToAccount, creds.Certificate) - am.server.store.Update(func(tx *buntdb.Tx) error { - if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount { - tx.Delete(certFPKey) - } - return nil - }) + if err = json.Unmarshal([]byte(credText), &creds); err == nil { + for _, cert := range creds.Certfps { + certFPKey := fmt.Sprintf(keyCertToAccount, cert) + am.server.store.Update(func(tx *buntdb.Tx) error { + if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount { + tx.Delete(certFPKey) + } + return nil + }) + } } } @@ -1326,7 +1403,73 @@ type AccountCredentials struct { Version uint PassphraseSalt []byte // legacy field, not used by v1 and later PassphraseHash []byte - Certificate string // fingerprint + Certfps []string +} + +func (ac *AccountCredentials) Empty() bool { + return len(ac.PassphraseHash) == 0 && len(ac.Certfps) == 0 +} + +// helper to assemble the serialized JSON for an account's credentials +func (ac *AccountCredentials) Serialize() (result string, err error) { + ac.Version = 1 + credText, err := json.Marshal(*ac) + if err != nil { + return "", err + } + return string(credText), nil +} + +func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) { + if passphrase == "" { + ac.PassphraseHash = nil + return nil + } + + if validatePassphrase(passphrase) != nil { + return errAccountBadPassphrase + } + + ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), int(bcryptCost)) + if err != nil { + return errAccountBadPassphrase + } + + return nil +} + +func (ac *AccountCredentials) AddCertfp(certfp string) (err error) { + for _, current := range ac.Certfps { + if certfp == current { + return errNoop + } + } + + if maxCertfpsPerAccount <= len(ac.Certfps) { + return errLimitExceeded + } + + ac.Certfps = append(ac.Certfps, certfp) + return nil +} + +func (ac *AccountCredentials) RemoveCertfp(certfp string) (err error) { + found := false + newList := make([]string, 0, len(ac.Certfps)) + for _, current := range ac.Certfps { + if current == certfp { + found = true + } else { + newList = append(newList, current) + } + } + if !found { + // this is important because it prevents you from deleting someone else's + // fingerprint record + return errNoop + } + ac.Certfps = newList + return nil } type BouncerAllowedSetting int diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 6c068a9c..b0875029 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 28 + numCapabs = 27 // length of the uint64 array that represents the bitset: bitsetLen = 1 ) @@ -37,11 +37,7 @@ const ( // https://ircv3.net/specs/extensions/chghost-3.2.html ChgHost Capability = iota - // Acc is the proposed IRCv3 capability named "draft/acc": - // https://github.com/ircv3/ircv3-specifications/pull/276 - Acc Capability = iota - - // EventPlayback is the Proposed IRCv3 capability named "draft/event-playback": + // EventPlayback is the proposed IRCv3 capability named "draft/event-playback": // https://github.com/ircv3/ircv3-specifications/pull/362 EventPlayback Capability = iota @@ -53,7 +49,7 @@ const ( // https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6 Languages Capability = iota - // Multiline is the Proposed IRCv3 capability named "draft/multiline": + // Multiline is the proposed IRCv3 capability named "draft/multiline": // https://github.com/ircv3/ircv3-specifications/pull/398 Multiline Capability = iota @@ -135,7 +131,6 @@ var ( "batch", "cap-notify", "chghost", - "draft/acc", "draft/event-playback", "draft/labeled-response-0.2", "draft/languages", diff --git a/irc/chanserv.go b/irc/chanserv.go index 35ad0e16..106e92b5 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -134,6 +134,7 @@ set using PURGE.`, INFO displays info about a registered channel.`, helpShort: `$bINFO$b displays info about a registered channel.`, + enabled: chanregEnabled, minParams: 1, }, } diff --git a/irc/client.go b/irc/client.go index c31e71cb..11b2a5e4 100644 --- a/irc/client.go +++ b/irc/client.go @@ -1419,10 +1419,11 @@ func (client *Client) attemptAutoOper(session *Session) { return } for _, oper := range client.server.Config().operators { - if oper.Auto && oper.Pass == nil && utils.CertfpsMatch(oper.Fingerprint, client.certfp) { + if oper.Auto && oper.Pass == nil && oper.Fingerprint != "" && oper.Fingerprint == client.certfp { rb := NewResponseBuffer(session) applyOper(client, oper, rb) rb.Send(true) + return } } } diff --git a/irc/commands.go b/irc/commands.go index 2f971cd1..859636b8 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -80,11 +80,6 @@ var Commands map[string]Command func init() { Commands = map[string]Command{ - "ACC": { - handler: accHandler, - usablePreReg: true, - minParams: 1, - }, "AMBIANCE": { handler: sceneHandler, minParams: 2, diff --git a/irc/config.go b/irc/config.go index 06409aa7..93e31836 100644 --- a/irc/config.go +++ b/irc/config.go @@ -511,7 +511,12 @@ func (conf *Config) Operators(oc map[string]*OperClass) (map[string]*Oper, error return nil, fmt.Errorf("Oper %s has an invalid password hash: %s", oper.Name, err.Error()) } } - oper.Fingerprint = opConf.Fingerprint + if opConf.Fingerprint != "" { + oper.Fingerprint, err = utils.NormalizeCertfp(opConf.Fingerprint) + if err != nil { + return nil, fmt.Errorf("Oper %s has an invalid fingerprint: %s", oper.Name, err.Error()) + } + } oper.Auto = opConf.Auto if oper.Pass == nil && oper.Fingerprint == "" { diff --git a/irc/database.go b/irc/database.go index 9cc4c3b9..12c9b83d 100644 --- a/irc/database.go +++ b/irc/database.go @@ -22,7 +22,7 @@ const ( // 'version' of the database schema keySchemaVersion = "db.version" // latest schema of the db - latestDbSchema = "8" + latestDbSchema = "9" ) type SchemaChanger func(*Config, *buntdb.Tx) error @@ -553,6 +553,57 @@ func schemaChangeV7ToV8(config *Config, tx *buntdb.Tx) error { return nil } +type accountCredsLegacyV8 struct { + Version uint + PassphraseSalt []byte // legacy field, not used by v1 and later + PassphraseHash []byte + Certificate string +} + +type accountCredsLegacyV9 struct { + Version uint + PassphraseSalt []byte // legacy field, not used by v1 and later + PassphraseHash []byte + Certfps []string +} + +// #530: support multiple client certificate fingerprints +func schemaChangeV8ToV9(config *Config, tx *buntdb.Tx) error { + prefix := "account.credentials " + var accounts, blobs []string + tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { + var legacy accountCredsLegacyV8 + var current accountCredsLegacyV9 + if !strings.HasPrefix(key, prefix) { + return false + } + account := strings.TrimPrefix(key, prefix) + err := json.Unmarshal([]byte(value), &legacy) + if err != nil { + log.Printf("corrupt record for %s: %v\n", account, err) + return true + } + current.Version = legacy.Version + current.PassphraseSalt = legacy.PassphraseSalt // ugh can't get rid of this + current.PassphraseHash = legacy.PassphraseHash + if legacy.Certificate != "" { + current.Certfps = []string{legacy.Certificate} + } + blob, err := json.Marshal(current) + if err != nil { + log.Printf("could not marshal record for %s: %v\n", account, err) + return true + } + accounts = append(accounts, account) + blobs = append(blobs, string(blob)) + return true + }) + for i, account := range accounts { + tx.Set(prefix+account, blobs[i], nil) + } + return nil +} + func init() { allChanges := []SchemaChange{ { @@ -590,6 +641,11 @@ func init() { TargetVersion: "8", Changer: schemaChangeV7ToV8, }, + { + InitialVersion: "8", + TargetVersion: "9", + Changer: schemaChangeV8ToV9, + }, } // build the index diff --git a/irc/errors.go b/irc/errors.go index 15f030dc..95690527 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -50,6 +50,10 @@ var ( errBanned = errors.New("IP or nickmask banned") errInvalidParams = utils.ErrInvalidParams errNoVhost = errors.New(`You do not have an approved vhost`) + errLimitExceeded = errors.New("Limit exceeded") + errNoop = errors.New("Action was a no-op") + errCASFailed = errors.New("Compare-and-swap update of database value failed") + errEmptyCredentials = errors.New("No more credentials are approved") ) // Socket Errors diff --git a/irc/gateways.go b/irc/gateways.go index ac94c654..98b68978 100644 --- a/irc/gateways.go +++ b/irc/gateways.go @@ -39,13 +39,17 @@ type webircConfig struct { // Populate fills out our password or fingerprint. func (wc *webircConfig) Populate() (err error) { if wc.Fingerprint == "" && wc.PasswordString == "" { - return ErrNoFingerprintOrPassword + err = ErrNoFingerprintOrPassword } - if wc.PasswordString != "" { + if err == nil && wc.PasswordString != "" { wc.Password, err = decodeLegacyPasswordHash(wc.PasswordString) } + if err == nil && wc.Fingerprint != "" { + wc.Fingerprint, err = utils.NormalizeCertfp(wc.Fingerprint) + } + if err == nil { wc.allowedNets, err = utils.ParseNetList(wc.Hosts) } diff --git a/irc/handlers.go b/irc/handlers.go index 6c897ccc..5a8fe1f8 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -31,52 +31,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -// ACC [LS|REGISTER|VERIFY] ... -func accHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - subcommand := strings.ToLower(msg.Params[0]) - - if subcommand == "ls" { - config := server.Config().Accounts - - rb.Add(nil, server.name, "ACC", "LS", "SUBCOMMANDS", "LS REGISTER VERIFY") - - // this list is sorted by the config loader, yay - rb.Add(nil, server.name, "ACC", "LS", "CALLBACKS", strings.Join(config.Registration.EnabledCallbacks, " ")) - - rb.Add(nil, server.name, "ACC", "LS", "CREDTYPES", "passphrase certfp") - - flags := []string{"nospaces"} - if config.NickReservation.Enabled { - flags = append(flags, "regnick") - } - sort.Strings(flags) - rb.Add(nil, server.name, "ACC", "LS", "FLAGS", strings.Join(flags, " ")) - return false - } - - // disallow account stuff before connection registration has completed, for now - if !client.Registered() { - client.Send(nil, server.name, ERR_NOTREGISTERED, "*", client.t("You need to register before you can use that command")) - return false - } - - // make sure reg is enabled - if !server.AccountConfig().Registration.Enabled { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_UNAVAILABLE", client.t("Account registration is disabled")) - return false - } - - if subcommand == "register" { - return accRegisterHandler(server, client, msg, rb) - } else if subcommand == "verify" { - return accVerifyHandler(server, client, msg, rb) - } else { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "ACC", msg.Params[0], client.t("Unknown subcommand")) - } - - return false -} - // helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234 func parseCallback(spec string, config *AccountConfig) (callbackNamespace string, callbackValue string) { callback := strings.ToLower(spec) @@ -103,113 +57,6 @@ func parseCallback(spec string, config *AccountConfig) (callbackNamespace string return } -// ACC REGISTER [callback_namespace:] [cred_type] : -func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - nick := client.Nick() - - if len(msg.Params) < 4 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, nick, msg.Command, client.t("Not enough parameters")) - return false - } - - account := msg.Params[1] - - // check for account name of * - if account == "*" { - account = nick - } else { - if server.Config().Accounts.NickReservation.Enabled { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_MUST_USE_REGNICK", account, client.t("Must register with current nickname instead of separate account name")) - return false - } - } - - // clients can't reg new accounts if they're already logged in - if client.LoggedIntoAccount() { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_UNSPECIFIED_ERROR", account, client.t("You're already logged into an account")) - return false - } - - // sanitise account name - casefoldedAccount, err := CasefoldName(account) - if err != nil { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_ACCOUNT_NAME", account, client.t("Account name is not valid")) - return false - } - - callbackSpec := msg.Params[2] - callbackNamespace, callbackValue := parseCallback(callbackSpec, server.AccountConfig()) - - if callbackNamespace == "" { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_CALLBACK", account, callbackSpec, client.t("Cannot send verification code there")) - return false - } - - // get credential type/value - var credentialType, credentialValue string - - if len(msg.Params) > 4 { - credentialType = strings.ToLower(msg.Params[3]) - credentialValue = msg.Params[4] - } else { - // exactly 4 params - credentialType = "passphrase" // default from the spec - credentialValue = msg.Params[3] - } - - // ensure the credential type is valid - var credentialValid bool - for _, name := range server.AccountConfig().Registration.EnabledCredentialTypes { - if credentialType == name { - credentialValid = true - } - } - if credentialType == "certfp" && client.certfp == "" { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_CREDENTIAL", account, client.t("You must connect with a TLS client certificate to use certfp")) - return false - } - - if !credentialValid { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_INVALID_CRED_TYPE", account, credentialType, client.t("Credential type is not supported")) - return false - } - - var passphrase, certfp string - if credentialType == "certfp" { - certfp = client.certfp - } else if credentialType == "passphrase" { - passphrase = credentialValue - } - - throttled, remainingTime := client.loginThrottle.Touch() - if throttled { - rb.Add(nil, server.name, "FAIL", "ACC", "REG_UNSPECIFIED_ERROR", account, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime)) - return false - } - - err = server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, certfp) - if err != nil { - msg, code := registrationErrorToMessageAndCode(err) - rb.Add(nil, server.name, "FAIL", "ACC", code, account, client.t(msg)) - return false - } - - // automatically complete registration - if callbackNamespace == "*" { - err := server.accounts.Verify(client, casefoldedAccount, "") - if err != nil { - return false - } - sendSuccessfulRegResponse(client, rb, false) - } else { - messageTemplate := client.t("Account created, pending verification; verification code has been sent to %s") - message := fmt.Sprintf(messageTemplate, fmt.Sprintf("%s:%s", callbackNamespace, callbackValue)) - rb.Add(nil, server.name, RPL_REG_VERIFICATION_REQUIRED, nick, casefoldedAccount, message) - } - - return false -} - func registrationErrorToMessageAndCode(err error) (message, code string) { // default responses: let's be risk-averse about displaying internal errors // to the clients, especially for something as sensitive as accounts @@ -263,41 +110,6 @@ func sendSuccessfulAccountAuth(client *Client, rb *ResponseBuffer, forNS, forSAS client.server.logger.Info("accounts", "client", details.nick, "logged into account", details.accountName) } -// ACC VERIFY -func accVerifyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - account := strings.TrimSpace(msg.Params[1]) - - if len(msg.Params) < 3 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) - return false - } - - err := server.accounts.Verify(client, account, msg.Params[2]) - - var code string - var message string - - if err == errAccountVerificationInvalidCode { - code = "ACCOUNT_INVALID_VERIFY_CODE" - message = err.Error() - } else if err == errAccountAlreadyVerified { - code = "ACCOUNT_ALREADY_VERIFIED" - message = err.Error() - } else if err != nil { - code = "VERIFY_UNSPECIFIED_ERROR" - message = errAccountVerificationFailed.Error() - } - - if err == nil { - rb.Add(nil, server.name, RPL_VERIFY_SUCCESS, client.Nick(), account, client.t("Account verification successful")) - sendSuccessfulAccountAuth(client, rb, false, false) - } else { - rb.Add(nil, server.name, "FAIL", "ACC", code, account, client.t(message)) - } - - return false -} - // AUTHENTICATE [||*] func authenticateHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { config := server.Config() @@ -2300,7 +2112,7 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp oper := server.GetOperator(msg.Params[0]) if oper != nil { if oper.Fingerprint != "" { - if utils.CertfpsMatch(oper.Fingerprint, client.certfp) { + if oper.Fingerprint == client.certfp { checkPassed = true } else { checkFailed = true @@ -2772,7 +2584,7 @@ func webircHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil { continue } - if 0 < len(info.Fingerprint) && !utils.CertfpsMatch(info.Fingerprint, client.certfp) { + if info.Fingerprint != "" && info.Fingerprint != client.certfp { continue } diff --git a/irc/legacy.go b/irc/legacy.go index e320b21f..2a1d8b6b 100644 --- a/irc/legacy.go +++ b/irc/legacy.go @@ -63,7 +63,7 @@ func handleLegacyPasswordV0(server *Server, account string, credentials AccountC } // upgrade credentials - err = server.accounts.setPassword(account, passphrase) + err = server.accounts.setPassword(account, passphrase, true) if err != nil { server.logger.Error("internal", fmt.Sprintf("could not upgrade user password: %v", err)) } diff --git a/irc/nickserv.go b/irc/nickserv.go index 7a5b7609..905e5ef2 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -12,6 +12,7 @@ import ( "github.com/goshuirc/irc-go/ircfmt" "github.com/oragono/oragono/irc/modes" + "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/sno" "github.com/oragono/oragono/irc/utils" ) @@ -72,6 +73,7 @@ entry for $bSET$b for more information.`, GHOST disconnects the given user from the network if they're logged in with the same user account, letting you reclaim your nickname.`, helpShort: `$bGHOST$b reclaims your nickname.`, + enabled: servCmdRequiresAuthEnabled, authRequired: true, minParams: 1, }, @@ -92,6 +94,7 @@ will not be able to use it.`, IDENTIFY lets you login to the given username using either password auth, or certfp (your client certificate) if a password is not given.`, helpShort: `$bIDENTIFY$b lets you login to your account.`, + enabled: servCmdRequiresAuthEnabled, minParams: 1, }, "info": { @@ -179,7 +182,8 @@ Or: $bPASSWD $b PASSWD lets you change your account password. You must supply your current password and confirm the new one by typing it twice. If you're an IRC operator with the correct permissions, you can use PASSWD to reset someone else's -password by supplying their username and then the desired password.`, +password by supplying their username and then the desired password. To +indicate an empty password, use * instead.`, helpShort: `$bPASSWD$b lets you change your password.`, enabled: servCmdRequiresAuthEnabled, minParams: 2, @@ -259,6 +263,20 @@ information on the settings and their possible values, see HELP SET.`, minParams: 3, capabs: []string{"accreg"}, }, + "cert": { + handler: nsCertHandler, + help: `Syntax: $bCERT [account] [certfp]$b + +CERT examines or modifies the TLS certificate fingerprints that can be used to +log into an account. Specifically, $bCERT LIST$b lists the authorized +fingerprints, $bCERT ADD $b adds a new fingerprint, and +$bCERT DEL $b removes a fingerprint. If you're an IRC operator +with the correct permissions, you can act on another user's account, for +example with $bCERT ADD $b.`, + helpShort: `$bCERT$b controls a user account's certificate fingerprints`, + enabled: servCmdRequiresAuthEnabled, + minParams: 1, + }, } ) @@ -548,6 +566,11 @@ func nsIdentifyHandler(server *Server, client *Client, command string, params [] } func nsInfoHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + if !server.Config().Accounts.AuthenticationEnabled && !client.HasRoleCapabs("accreg") { + nsNotice(rb, client.t("This command has been disabled by the server administrators")) + return + } + var accountName string if len(params) > 0 { nick := params[0] @@ -659,6 +682,9 @@ func nsRegisterHandler(server *Server, client *Client, command string, params [] func nsSaregisterHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { account, passphrase := params[0], params[1] + if passphrase == "*" { + passphrase = "" + } err := server.accounts.Register(nil, account, "admin", "", passphrase, "") if err == nil { err = server.accounts.Verify(nil, account, "") @@ -753,30 +779,40 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st var errorMessage string hasPrivs := client.HasRoleCapabs("accreg") - if !hasPrivs && !nsLoginThrottleCheck(client, rb) { - return - } switch len(params) { case 2: if !hasPrivs { - errorMessage = "Insufficient privileges" + errorMessage = `Insufficient privileges` } else { target, newPassword = params[0], params[1] + if newPassword == "*" { + newPassword = "" + } } case 3: target = client.Account() if target == "" { - errorMessage = "You're not logged into an account" + errorMessage = `You're not logged into an account` } else if params[1] != params[2] { - errorMessage = "Passwords do not match" + errorMessage = `Passwords do not match` } else { - // check that they correctly supplied the preexisting password - _, err := server.accounts.checkPassphrase(target, params[0]) + if !nsLoginThrottleCheck(client, rb) { + return + } + accountData, err := server.accounts.LoadAccount(target) if err != nil { - errorMessage = "Password incorrect" + errorMessage = `You're not logged into an account` } else { - newPassword = params[1] + hash := accountData.Credentials.PassphraseHash + if hash != nil && passwd.CompareHashAndPassword(hash, []byte(params[0])) != nil { + errorMessage = `Password incorrect` + } else { + newPassword = params[1] + if newPassword == "*" { + newPassword = "" + } + } } } default: @@ -788,10 +824,15 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st return } - err := server.accounts.setPassword(target, newPassword) - if err == nil { + err := server.accounts.setPassword(target, newPassword, hasPrivs) + switch err { + case nil: nsNotice(rb, client.t("Password changed")) - } else { + case errEmptyCredentials: + nsNotice(rb, client.t("You can't delete your password unless you add a certificate fingerprint")) + case errCASFailed: + nsNotice(rb, client.t("Try again later")) + default: server.logger.Error("internal", "could not upgrade user password:", err.Error()) nsNotice(rb, client.t("Password could not be changed due to server error")) } @@ -837,3 +878,93 @@ func nsSessionsHandler(server *Server, client *Client, command string, params [] nsNotice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123))) } } + +func nsCertHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + verb := strings.ToLower(params[0]) + params = params[1:] + var target, certfp string + + switch verb { + case "list": + if 1 <= len(params) { + target = params[0] + } + case "add", "del": + if 2 <= len(params) { + target, certfp = params[0], params[1] + } else if len(params) == 1 { + certfp = params[0] + } else { + nsNotice(rb, client.t("Invalid parameters")) + return + } + default: + nsNotice(rb, client.t("Invalid parameters")) + return + } + + hasPrivs := client.HasRoleCapabs("accreg") + if target != "" && !hasPrivs { + nsNotice(rb, client.t("Insufficient privileges")) + return + } else if target == "" { + target = client.Account() + if target == "" { + nsNotice(rb, client.t("You're not logged into an account")) + return + } + } + + var err error + switch verb { + case "list": + accountData, err := server.accounts.LoadAccount(target) + if err == errAccountDoesNotExist { + nsNotice(rb, client.t("Account does not exist")) + return + } else if err != nil { + nsNotice(rb, client.t("An error occurred")) + return + } + certfps := accountData.Credentials.Certfps + nsNotice(rb, fmt.Sprintf(client.t("There are %d certificate fingerprint(s) authorized for account %s."), len(certfps), accountData.Name)) + for i, certfp := range certfps { + nsNotice(rb, fmt.Sprintf("%d: %s", i+1, certfp)) + } + return + case "add": + err = server.accounts.addRemoveCertfp(target, certfp, true, hasPrivs) + case "del": + err = server.accounts.addRemoveCertfp(target, certfp, false, hasPrivs) + } + + switch err { + case nil: + if verb == "add" { + nsNotice(rb, client.t("Certificate fingerprint successfully added")) + } else { + nsNotice(rb, client.t("Certificate fingerprint successfully removed")) + } + case errNoop: + if verb == "add" { + nsNotice(rb, client.t("That certificate fingerprint was already authorized")) + } else { + nsNotice(rb, client.t("Certificate fingerprint not found")) + } + case errAccountDoesNotExist: + nsNotice(rb, client.t("Account does not exist")) + case errLimitExceeded: + nsNotice(rb, client.t("You already have too many certificate fingerprints")) + case utils.ErrInvalidCertfp: + nsNotice(rb, client.t("Invalid certificate fingerprint")) + case errCertfpAlreadyExists: + nsNotice(rb, client.t("That certificate fingerprint is already associated with another account")) + case errEmptyCredentials: + nsNotice(rb, client.t("You can't remove all your certificate fingerprints unless you add a password")) + case errCASFailed: + nsNotice(rb, client.t("Try again later")) + default: + server.logger.Error("internal", "could not modify certificates:", err.Error()) + nsNotice(rb, client.t("An error occurred")) + } +} diff --git a/irc/utils/crypto.go b/irc/utils/crypto.go index be4247ff..d3c8b920 100644 --- a/irc/utils/crypto.go +++ b/irc/utils/crypto.go @@ -8,12 +8,16 @@ import ( "crypto/subtle" "encoding/base32" "encoding/base64" + "encoding/hex" + "errors" "strings" ) var ( // slingamn's own private b32 alphabet, removing 1, l, o, and 0 B32Encoder = base32.NewEncoding("abcdefghijkmnpqrstuvwxyz23456789").WithPadding(base32.NoPadding) + + ErrInvalidCertfp = errors.New("Invalid certfp") ) const ( @@ -70,14 +74,12 @@ func GenerateSecretKey() string { return base64.RawURLEncoding.EncodeToString(buf[:]) } -func normalizeCertfp(certfp string) string { - return strings.ToLower(strings.Replace(certfp, ":", "", -1)) -} - -// Convenience to compare certfps as returned by different tools, e.g., openssl vs. oragono -func CertfpsMatch(storedCertfp, suppliedCertfp string) bool { - if storedCertfp == "" { - return false +// Normalize openssl-formatted certfp's to oragono's format +func NormalizeCertfp(certfp string) (result string, err error) { + result = strings.ToLower(strings.Replace(certfp, ":", "", -1)) + decoded, err := hex.DecodeString(result) + if err != nil || len(decoded) != 32 { + return "", ErrInvalidCertfp } - return normalizeCertfp(storedCertfp) == normalizeCertfp(suppliedCertfp) + return } diff --git a/irc/utils/crypto_test.go b/irc/utils/crypto_test.go index 0b0b6967..8140e683 100644 --- a/irc/utils/crypto_test.go +++ b/irc/utils/crypto_test.go @@ -85,17 +85,23 @@ func BenchmarkMungeSecretToken(b *testing.B) { func TestCertfpComparisons(t *testing.T) { opensslFP := "3D:6B:11:BF:B4:05:C3:F8:4B:38:CD:30:38:FB:EC:01:71:D5:03:54:79:04:07:88:4C:A5:5D:23:41:85:66:C9" oragonoFP := "3d6b11bfb405c3f84b38cd3038fbec0171d50354790407884ca55d23418566c9" - badFP := "3d6b11bfb405c3f84b38cd3038fbec0171d50354790407884ca55d23418566c8" - if !CertfpsMatch(opensslFP, oragonoFP) { - t.Error("these certs should match") + badFP := "3d6b11bfb405c3f84b38cd3038fbec0171d50354790407884ca55d23418566c" + badFP2 := "*" + + normalizedOpenssl, err := NormalizeCertfp(opensslFP) + assertEqual(err, nil, t) + assertEqual(normalizedOpenssl, oragonoFP, t) + + normalizedOragono, err := NormalizeCertfp(oragonoFP) + assertEqual(err, nil, t) + assertEqual(normalizedOragono, oragonoFP, t) + + _, err = NormalizeCertfp(badFP) + if err == nil { + t.Errorf("corrupt fp should fail normalization") } - if !CertfpsMatch(oragonoFP, opensslFP) { - t.Error("these certs should match") - } - if CertfpsMatch("", "") { - t.Error("empty stored certfp should not match empty provided certfp") - } - if CertfpsMatch(opensslFP, badFP) { - t.Error("these certs should not match") + _, err = NormalizeCertfp(badFP2) + if err == nil { + t.Errorf("corrupt fp should fail normalization") } } diff --git a/oragono.yaml b/oragono.yaml index bdee854f..0bf92a3c 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -244,6 +244,9 @@ server: # account options accounts: + # is account authentication enabled, i.e., can users log into existing accounts? + authentication-enabled: true + # account registration registration: # can users register new accounts for themselves? if this is false, operators with @@ -271,9 +274,6 @@ accounts: # password: "" # sender: "admin@my.network" - # is account authentication enabled? - authentication-enabled: true - # throttle account login attempts (to prevent either password guessing, or DoS # attacks on the server aimed at forcing repeated expensive bcrypt computations) login-throttling: