This commit is contained in:
Shivaram Lingamneni 2019-12-29 11:59:49 -05:00
parent 9de9fcf069
commit f920d3b79f
16 changed files with 454 additions and 305 deletions

View File

@ -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",
),
]

View File

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

View File

@ -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",

View File

@ -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,
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <accountname> [callback_namespace:]<callback> [cred_type] :<credential>
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 <accountname> <auth_code>
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 [<mechanism>|<data>|*]
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
}

View File

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

View File

@ -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 <username> <new>$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 <LIST | ADD | DEL> [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 <fingerprint>$b adds a new fingerprint, and
$bCERT DEL <fingerprint>$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 <account> <fingerprint>$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"))
}
}

View File

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

View File

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

View File

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