3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-12-22 10:42:52 +01:00

use the TR39 skeleton algorithm to prevent confusables (#178)

This commit is contained in:
Shivaram Lingamneni 2019-01-30 18:59:49 -05:00
parent a11486d699
commit b9b2553a2f
9 changed files with 271 additions and 76 deletions

View File

@ -52,17 +52,19 @@ type AccountManager struct {
server *Server server *Server
// track clients logged in to accounts // track clients logged in to accounts
accountToClients map[string][]*Client accountToClients map[string][]*Client
nickToAccount map[string]string nickToAccount map[string]string
accountToMethod map[string]NickReservationMethod skeletonToAccount map[string]string
accountToMethod map[string]NickReservationMethod
} }
func NewAccountManager(server *Server) *AccountManager { func NewAccountManager(server *Server) *AccountManager {
am := AccountManager{ am := AccountManager{
accountToClients: make(map[string][]*Client), accountToClients: make(map[string][]*Client),
nickToAccount: make(map[string]string), nickToAccount: make(map[string]string),
accountToMethod: make(map[string]NickReservationMethod), skeletonToAccount: make(map[string]string),
server: server, accountToMethod: make(map[string]NickReservationMethod),
server: server,
} }
am.buildNickToAccountIndex() am.buildNickToAccountIndex()
@ -76,6 +78,7 @@ func (am *AccountManager) buildNickToAccountIndex() {
} }
nickToAccount := make(map[string]string) nickToAccount := make(map[string]string)
skeletonToAccount := make(map[string]string)
accountToMethod := make(map[string]NickReservationMethod) accountToMethod := make(map[string]NickReservationMethod)
existsPrefix := fmt.Sprintf(keyAccountExists, "") existsPrefix := fmt.Sprintf(keyAccountExists, "")
@ -91,11 +94,21 @@ func (am *AccountManager) buildNickToAccountIndex() {
account := strings.TrimPrefix(key, existsPrefix) account := strings.TrimPrefix(key, existsPrefix)
if _, err := tx.Get(fmt.Sprintf(keyAccountVerified, account)); err == nil { if _, err := tx.Get(fmt.Sprintf(keyAccountVerified, account)); err == nil {
nickToAccount[account] = account nickToAccount[account] = account
accountName, err := tx.Get(fmt.Sprintf(keyAccountName, account))
if err != nil {
am.server.logger.Error("internal", "missing account name for", account)
} else {
skeleton, _ := Skeleton(accountName)
skeletonToAccount[skeleton] = account
}
} }
if rawNicks, err := tx.Get(fmt.Sprintf(keyAccountAdditionalNicks, account)); err == nil { if rawNicks, err := tx.Get(fmt.Sprintf(keyAccountAdditionalNicks, account)); err == nil {
additionalNicks := unmarshalReservedNicks(rawNicks) additionalNicks := unmarshalReservedNicks(rawNicks)
for _, nick := range additionalNicks { for _, nick := range additionalNicks {
nickToAccount[nick] = account cfnick, _ := CasefoldName(nick)
nickToAccount[cfnick] = account
skeleton, _ := Skeleton(nick)
skeletonToAccount[skeleton] = account
} }
} }
@ -115,6 +128,7 @@ func (am *AccountManager) buildNickToAccountIndex() {
} else { } else {
am.Lock() am.Lock()
am.nickToAccount = nickToAccount am.nickToAccount = nickToAccount
am.skeletonToAccount = skeletonToAccount
am.accountToMethod = accountToMethod am.accountToMethod = accountToMethod
am.Unlock() am.Unlock()
} }
@ -171,36 +185,55 @@ func (am *AccountManager) NickToAccount(nick string) string {
// Given a nick, looks up the account that owns it and the method (none/timeout/strict) // Given a nick, looks up the account that owns it and the method (none/timeout/strict)
// used to enforce ownership. // used to enforce ownership.
func (am *AccountManager) EnforcementStatus(nick string) (account string, method NickReservationMethod) { func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickReservationMethod) {
cfnick, err := CasefoldName(nick)
if err != nil {
return
}
config := am.server.Config() config := am.server.Config()
if !config.Accounts.NickReservation.Enabled { if !config.Accounts.NickReservation.Enabled {
method = NickReservationNone return "", NickReservationNone
return
} }
am.RLock() am.RLock()
defer am.RUnlock() defer am.RUnlock()
account = am.nickToAccount[cfnick] // given an account, combine stored enforcement method with the config settings
if account == "" { // to compute the actual enforcement method
method = NickReservationNone finalEnforcementMethod := func(account_ string) (result NickReservationMethod) {
result = am.accountToMethod[account_]
// if they don't have a custom setting, or customization is disabled, use the default
if result == NickReservationOptional || !config.Accounts.NickReservation.AllowCustomEnforcement {
result = config.Accounts.NickReservation.Method
}
if result == NickReservationOptional {
// enforcement was explicitly enabled neither in the config or by the user
result = NickReservationNone
}
return return
} }
method = am.accountToMethod[account]
// if they don't have a custom setting, or customization is disabled, use the default nickAccount := am.nickToAccount[cfnick]
if method == NickReservationOptional || !config.Accounts.NickReservation.AllowCustomEnforcement { skelAccount := am.skeletonToAccount[skeleton]
method = config.Accounts.NickReservation.Method if nickAccount == "" && skelAccount == "" {
return "", NickReservationNone
} else if nickAccount != "" && skelAccount != "" && nickAccount != skelAccount {
// two people have competing claims on (this casefolding of) this nick!
nickMethod := finalEnforcementMethod(nickAccount)
skelMethod := finalEnforcementMethod(skelAccount)
switch {
case nickMethod == NickReservationNone && skelMethod == NickReservationNone:
return "", NickReservationNone
case skelMethod == NickReservationNone:
return nickAccount, nickMethod
case nickMethod == NickReservationNone:
return skelAccount, skelMethod
default:
// nobody can use this nick
return "!", NickReservationStrict
}
} else if nickAccount == "" && skelAccount != "" {
// skeleton owner is the only owner; fall through to normal case
nickAccount = skelAccount
} }
if method == NickReservationOptional { // else: nickAccount != "" && skelAccount == "", nickAccount is the only owner
// enforcement was explicitly enabled neither in the config or by the user return nickAccount, finalEnforcementMethod(nickAccount)
method = NickReservationNone
}
return
} }
// Looks up the enforcement method stored in the database for an account // Looks up the enforcement method stored in the database for an account
@ -264,10 +297,15 @@ func (am *AccountManager) AccountToClients(account string) (result []*Client) {
func (am *AccountManager) Register(client *Client, account string, callbackNamespace string, callbackValue string, passphrase string, certfp string) error { func (am *AccountManager) Register(client *Client, account string, callbackNamespace string, callbackValue string, passphrase string, certfp string) error {
casefoldedAccount, err := CasefoldName(account) casefoldedAccount, err := CasefoldName(account)
if err != nil || account == "" || account == "*" { skeleton, skerr := Skeleton(account)
if err != nil || skerr != nil || account == "" || account == "*" {
return errAccountCreation return errAccountCreation
} }
if restrictedNicknames[casefoldedAccount] || restrictedNicknames[skeleton] {
return errAccountAlreadyRegistered
}
// can't register a guest nickname // can't register a guest nickname
config := am.server.AccountConfig() config := am.server.AccountConfig()
renamePrefix := strings.ToLower(config.NickReservation.RenamePrefix) renamePrefix := strings.ToLower(config.NickReservation.RenamePrefix)
@ -535,8 +573,10 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
}) })
if err == nil { if err == nil {
skeleton, _ := Skeleton(raw.Name)
am.Lock() am.Lock()
am.nickToAccount[casefoldedAccount] = casefoldedAccount am.nickToAccount[casefoldedAccount] = casefoldedAccount
am.skeletonToAccount[skeleton] = casefoldedAccount
am.Unlock() am.Unlock()
} }
}() }()
@ -567,9 +607,10 @@ func unmarshalReservedNicks(nicks string) (result []string) {
func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreserve bool, reserve bool) error { func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreserve bool, reserve bool) error {
cfnick, err := CasefoldName(nick) cfnick, err := CasefoldName(nick)
skeleton, skerr := Skeleton(nick)
// garbage nick, or garbage options, or disabled // garbage nick, or garbage options, or disabled
nrconfig := am.server.AccountConfig().NickReservation nrconfig := am.server.AccountConfig().NickReservation
if err != nil || cfnick == "" || (reserve && saUnreserve) || !nrconfig.Enabled { if err != nil || skerr != nil || cfnick == "" || (reserve && saUnreserve) || !nrconfig.Enabled {
return errAccountNickReservationFailed return errAccountNickReservationFailed
} }
@ -591,8 +632,15 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser
return errAccountNotLoggedIn return errAccountNotLoggedIn
} }
accountForNick := am.NickToAccount(cfnick) am.Lock()
if reserve && accountForNick != "" { accountForNick := am.nickToAccount[cfnick]
var accountForSkeleton string
if reserve {
accountForSkeleton = am.skeletonToAccount[skeleton]
}
am.Unlock()
if reserve && (accountForNick != "" || accountForSkeleton != "") {
return errNicknameReserved return errNicknameReserved
} else if !reserve && !saUnreserve && accountForNick != account { } else if !reserve && !saUnreserve && accountForNick != account {
return errNicknameReserved return errNicknameReserved
@ -623,12 +671,18 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser
if len(nicks) >= nrconfig.AdditionalNickLimit { if len(nicks) >= nrconfig.AdditionalNickLimit {
return errAccountTooManyNicks return errAccountTooManyNicks
} }
nicks = append(nicks, cfnick) nicks = append(nicks, nick)
} else { } else {
// compute (original reserved nicks) minus cfnick
var newNicks []string var newNicks []string
for _, reservedNick := range nicks { for _, reservedNick := range nicks {
if reservedNick != cfnick { cfreservednick, _ := CasefoldName(reservedNick)
if cfreservednick != cfnick {
newNicks = append(newNicks, reservedNick) newNicks = append(newNicks, reservedNick)
} else {
// found the original, unfolded version of the nick we're dropping;
// recompute the true skeleton from it
skeleton, _ = Skeleton(reservedNick)
} }
} }
nicks = newNicks nicks = newNicks
@ -650,8 +704,10 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser
defer am.Unlock() defer am.Unlock()
if reserve { if reserve {
am.nickToAccount[cfnick] = account am.nickToAccount[cfnick] = account
am.skeletonToAccount[skeleton] = account
} else { } else {
delete(am.nickToAccount, cfnick) delete(am.nickToAccount, cfnick)
delete(am.skeletonToAccount, skeleton)
} }
return nil return nil
} }
@ -787,8 +843,10 @@ func (am *AccountManager) Unregister(account string) error {
am.serialCacheUpdateMutex.Lock() am.serialCacheUpdateMutex.Lock()
defer am.serialCacheUpdateMutex.Unlock() defer am.serialCacheUpdateMutex.Unlock()
var accountName string
am.server.store.Update(func(tx *buntdb.Tx) error { am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Delete(accountKey) tx.Delete(accountKey)
accountName, _ = tx.Get(accountNameKey)
tx.Delete(accountNameKey) tx.Delete(accountNameKey)
tx.Delete(verifiedKey) tx.Delete(verifiedKey)
tx.Delete(registeredTimeKey) tx.Delete(registeredTimeKey)
@ -817,6 +875,7 @@ func (am *AccountManager) Unregister(account string) error {
} }
} }
skeleton, _ := Skeleton(accountName)
additionalNicks := unmarshalReservedNicks(rawNicks) additionalNicks := unmarshalReservedNicks(rawNicks)
am.Lock() am.Lock()
@ -825,8 +884,11 @@ func (am *AccountManager) Unregister(account string) error {
clients = am.accountToClients[casefoldedAccount] clients = am.accountToClients[casefoldedAccount]
delete(am.accountToClients, casefoldedAccount) delete(am.accountToClients, casefoldedAccount)
delete(am.nickToAccount, casefoldedAccount) delete(am.nickToAccount, casefoldedAccount)
delete(am.skeletonToAccount, skeleton)
for _, nick := range additionalNicks { for _, nick := range additionalNicks {
delete(am.nickToAccount, nick) delete(am.nickToAccount, nick)
additionalSkel, _ := Skeleton(nick)
delete(am.skeletonToAccount, additionalSkel)
} }
for _, client := range clients { for _, client := range clients {
am.logoutOfAccount(client) am.logoutOfAccount(client)

View File

@ -95,6 +95,7 @@ type Client struct {
saslMechanism string saslMechanism string
saslValue string saslValue string
server *Server server *Server
skeleton string
socket *Socket socket *Socket
stateMutex sync.RWMutex // tier 1 stateMutex sync.RWMutex // tier 1
username string username string
@ -381,7 +382,7 @@ func (client *Client) Register() {
client.TryResume() client.TryResume()
// finish registration // finish registration
client.updateNickMask("") client.updateNickMask()
client.server.monitorManager.AlertAbout(client, true) client.server.monitorManager.AlertAbout(client, true)
} }
@ -565,6 +566,7 @@ func (client *Client) copyResumeData(oldClient *Client) {
vhost := oldClient.vhost vhost := oldClient.vhost
account := oldClient.account account := oldClient.account
accountName := oldClient.accountName accountName := oldClient.accountName
skeleton := oldClient.skeleton
oldClient.stateMutex.RUnlock() oldClient.stateMutex.RUnlock()
// copy all flags, *except* TLS (in the case that the admins enabled // copy all flags, *except* TLS (in the case that the admins enabled
@ -586,6 +588,7 @@ func (client *Client) copyResumeData(oldClient *Client) {
client.vhost = vhost client.vhost = vhost
client.account = account client.account = account
client.accountName = accountName client.accountName = accountName
client.skeleton = skeleton
client.updateNickMaskNoMutex() client.updateNickMaskNoMutex()
} }
@ -696,6 +699,14 @@ func (client *Client) Friends(capabs ...caps.Capability) ClientSet {
return friends return friends
} }
func (client *Client) SetOper(oper *Oper) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
client.oper = oper
// operators typically get a vhost, update the nickmask
client.updateNickMaskNoMutex()
}
// XXX: CHGHOST requires prefix nickmask to have original hostname, // XXX: CHGHOST requires prefix nickmask to have original hostname,
// this is annoying to do correctly // this is annoying to do correctly
func (client *Client) sendChghost(oldNickMask string, vhost string) { func (client *Client) sendChghost(oldNickMask string, vhost string) {
@ -730,32 +741,23 @@ func (client *Client) SetVHost(vhost string) (updated bool) {
} }
// updateNick updates `nick` and `nickCasefolded`. // updateNick updates `nick` and `nickCasefolded`.
func (client *Client) updateNick(nick string) { func (client *Client) updateNick(nick, nickCasefolded, skeleton string) {
casefoldedName, err := CasefoldName(nick)
if err != nil {
client.server.logger.Error("internal", "nick couldn't be casefolded", nick, err.Error())
return
}
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock()
client.nick = nick client.nick = nick
client.nickCasefolded = casefoldedName client.nickCasefolded = nickCasefolded
client.stateMutex.Unlock() client.skeleton = skeleton
client.updateNickMaskNoMutex()
} }
// updateNickMask updates the casefolded nickname and nickmask. // updateNickMask updates the nickmask.
func (client *Client) updateNickMask(nick string) { func (client *Client) updateNickMask() {
// on "", just regenerate the nickmask etc.
// otherwise, update the actual nick
if nick != "" {
client.updateNick(nick)
}
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
client.updateNickMaskNoMutex() client.updateNickMaskNoMutex()
} }
// updateNickMask updates the casefolded nickname and nickmask, not acquiring any mutexes. // updateNickMaskNoMutex updates the casefolded nickname and nickmask, not acquiring any mutexes.
func (client *Client) updateNickMaskNoMutex() { func (client *Client) updateNickMaskNoMutex() {
client.hostname = client.getVHostNoMutex() client.hostname = client.getVHostNoMutex()
if client.hostname == "" { if client.hostname == "" {

View File

@ -34,12 +34,14 @@ func ExpandUserHost(userhost string) (expanded string) {
type ClientManager struct { type ClientManager struct {
sync.RWMutex // tier 2 sync.RWMutex // tier 2
byNick map[string]*Client byNick map[string]*Client
bySkeleton map[string]*Client
} }
// NewClientManager returns a new ClientManager. // NewClientManager returns a new ClientManager.
func NewClientManager() *ClientManager { func NewClientManager() *ClientManager {
return &ClientManager{ return &ClientManager{
byNick: make(map[string]*Client), byNick: make(map[string]*Client),
bySkeleton: make(map[string]*Client),
} }
} }
@ -65,7 +67,11 @@ func (clients *ClientManager) Get(nick string) *Client {
func (clients *ClientManager) removeInternal(client *Client) (err error) { func (clients *ClientManager) removeInternal(client *Client) (err error) {
// requires holding the writable Lock() // requires holding the writable Lock()
oldcfnick := client.NickCasefolded() oldcfnick, oldskeleton := client.uniqueIdentifiers()
if oldcfnick == "*" || oldcfnick == "" {
return errNickMissing
}
currentEntry, present := clients.byNick[oldcfnick] currentEntry, present := clients.byNick[oldcfnick]
if present { if present {
if currentEntry == client { if currentEntry == client {
@ -75,7 +81,22 @@ func (clients *ClientManager) removeInternal(client *Client) (err error) {
client.server.logger.Warning("internal", "clients for nick out of sync", oldcfnick) client.server.logger.Warning("internal", "clients for nick out of sync", oldcfnick)
err = errNickMissing err = errNickMissing
} }
} else {
err = errNickMissing
} }
currentEntry, present = clients.bySkeleton[oldskeleton]
if present {
if currentEntry == client {
delete(clients.bySkeleton, oldskeleton)
} else {
client.server.logger.Warning("internal", "clients for skeleton out of sync", oldskeleton)
err = errNickMissing
}
} else {
err = errNickMissing
}
return return
} }
@ -84,9 +105,6 @@ func (clients *ClientManager) Remove(client *Client) error {
clients.Lock() clients.Lock()
defer clients.Unlock() defer clients.Unlock()
if !client.HasNick() {
return errNickMissing
}
return clients.removeInternal(client) return clients.removeInternal(client)
} }
@ -105,7 +123,9 @@ func (clients *ClientManager) Resume(newClient, oldClient *Client) (err error) {
} }
// nick has been reclaimed, grant it to the new client // nick has been reclaimed, grant it to the new client
clients.removeInternal(newClient) clients.removeInternal(newClient)
clients.byNick[oldClient.NickCasefolded()] = newClient oldcfnick, oldskeleton := oldClient.uniqueIdentifiers()
clients.byNick[oldcfnick] = newClient
clients.bySkeleton[oldskeleton] = newClient
newClient.copyResumeData(oldClient) newClient.copyResumeData(oldClient)
@ -118,8 +138,12 @@ func (clients *ClientManager) SetNick(client *Client, newNick string) error {
if err != nil { if err != nil {
return err return err
} }
newSkeleton, err := Skeleton(newNick)
if err != nil {
return err
}
reservedAccount, method := client.server.accounts.EnforcementStatus(newcfnick) reservedAccount, method := client.server.accounts.EnforcementStatus(newcfnick, newSkeleton)
clients.Lock() clients.Lock()
defer clients.Unlock() defer clients.Unlock()
@ -129,12 +153,18 @@ func (clients *ClientManager) SetNick(client *Client, newNick string) error {
if currentNewEntry != nil && currentNewEntry != client { if currentNewEntry != nil && currentNewEntry != client {
return errNicknameInUse return errNicknameInUse
} }
// analogous checks for skeletons
skeletonHolder := clients.bySkeleton[newSkeleton]
if skeletonHolder != nil && skeletonHolder != client {
return errNicknameInUse
}
if method == NickReservationStrict && reservedAccount != "" && reservedAccount != client.Account() { if method == NickReservationStrict && reservedAccount != "" && reservedAccount != client.Account() {
return errNicknameReserved return errNicknameReserved
} }
clients.removeInternal(client) clients.removeInternal(client)
clients.byNick[newcfnick] = client clients.byNick[newcfnick] = client
client.updateNickMask(newNick) clients.bySkeleton[newSkeleton] = client
client.updateNick(newNick, newcfnick, newSkeleton)
return nil return nil
} }

View File

@ -108,6 +108,15 @@ func (client *Client) Realname() string {
return client.realname return client.realname
} }
// uniqueIdentifiers returns the strings for which the server enforces per-client
// uniqueness/ownership; no two clients can have colliding casefolded nicks or
// skeletons.
func (client *Client) uniqueIdentifiers() (nickCasefolded string, skeleton string) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return client.nickCasefolded, client.skeleton
}
func (client *Client) ResumeToken() string { func (client *Client) ResumeToken() string {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()
@ -120,12 +129,6 @@ func (client *Client) Oper() *Oper {
return client.oper return client.oper
} }
func (client *Client) SetOper(oper *Oper) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
client.oper = oper
}
func (client *Client) Registered() bool { func (client *Client) Registered() bool {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()

View File

@ -1706,7 +1706,6 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
oldNickmask := client.NickMaskString() oldNickmask := client.NickMaskString()
client.SetOper(oper) client.SetOper(oper)
client.updateNickMask("")
if client.NickMaskString() != oldNickmask { if client.NickMaskString() != oldNickmask {
client.sendChghost(oldNickmask, oper.Vhost) client.sendChghost(oldNickmask, oper.Vhost)
} }

View File

@ -205,9 +205,9 @@ func (nt *NickTimer) Touch() {
return return
} }
nick := nt.client.NickCasefolded() cfnick, skeleton := nt.client.uniqueIdentifiers()
account := nt.client.Account() account := nt.client.Account()
accountForNick, method := nt.client.server.accounts.EnforcementStatus(nick) accountForNick, method := nt.client.server.accounts.EnforcementStatus(cfnick, skeleton)
enforceTimeout := method == NickReservationWithTimeout enforceTimeout := method == NickReservationWithTimeout
var shouldWarn bool var shouldWarn bool
@ -223,7 +223,7 @@ func (nt *NickTimer) Touch() {
// the timer will not reset as long as the squatter is targeting the same account // the timer will not reset as long as the squatter is targeting the same account
accountChanged := accountForNick != nt.accountForNick accountChanged := accountForNick != nt.accountForNick
// change state // change state
nt.nick = nick nt.nick = cfnick
nt.account = account nt.account = account
nt.accountForNick = accountForNick nt.accountForNick = accountForNick
delinquent := accountForNick != "" && accountForNick != account delinquent := accountForNick != "" && accountForNick != account

View File

@ -215,7 +215,7 @@ func nsGhostHandler(server *Server, client *Client, command string, params []str
} }
func nsGroupHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { func nsGroupHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
nick := client.NickCasefolded() nick := client.Nick()
err := server.accounts.SetNickReserved(client, nick, false, true) err := server.accounts.SetNickReserved(client, nick, false, true)
if err == nil { if err == nil {
nsNotice(rb, fmt.Sprintf(client.t("Successfully grouped nick %s with your account"), nick)) nsNotice(rb, fmt.Sprintf(client.t("Successfully grouped nick %s with your account"), nick))

View File

@ -8,21 +8,25 @@ package irc
import ( import (
"strings" "strings"
"github.com/mtibben/confusables"
"golang.org/x/text/secure/precis" "golang.org/x/text/secure/precis"
"golang.org/x/text/unicode/norm"
) )
const ( const (
casemappingName = "rfc8265" casemappingName = "rfc8265"
) )
// Casefold returns a casefolded string, without doing any name or channel character checks. // Each pass of PRECIS casefolding is a composition of idempotent operations,
func Casefold(str string) (string, error) { // but not idempotent itself. Therefore, the spec says "do it four times and hope
var err error // it converges" (lolwtf). Golang's PRECIS implementation has a "repeat" option,
oldStr := str // which provides this functionality, but unfortunately it's not exposed publicly.
func iterateFolding(profile *precis.Profile, oldStr string) (str string, err error) {
str = oldStr
// follow the stabilizing rules laid out here: // follow the stabilizing rules laid out here:
// https://tools.ietf.org/html/draft-ietf-precis-7564bis-10.html#section-7 // https://tools.ietf.org/html/draft-ietf-precis-7564bis-10.html#section-7
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
str, err = precis.UsernameCaseMapped.CompareKey(str) str, err = profile.CompareKey(str)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -37,6 +41,11 @@ func Casefold(str string) (string, error) {
return str, nil return str, nil
} }
// Casefold returns a casefolded string, without doing any name or channel character checks.
func Casefold(str string) (string, error) {
return iterateFolding(precis.UsernameCaseMapped, str)
}
// CasefoldChannel returns a casefolded version of a channel name. // CasefoldChannel returns a casefolded version of a channel name.
func CasefoldChannel(name string) (string, error) { func CasefoldChannel(name string) (string, error) {
if len(name) == 0 { if len(name) == 0 {
@ -96,3 +105,46 @@ func CasefoldName(name string) (string, error) {
return lowered, err return lowered, err
} }
// "boring" names are exempt from skeletonization.
// this is because confusables.txt considers various pure ASCII alphanumeric
// strings confusable: 0 and O, 1 and l, m and rn. IMO this causes more problems
// than it solves.
func isBoring(name string) bool {
for i := 0; i < len(name); i += 1 {
chr := name[i]
if (chr >= 'a' && chr <= 'z') || (chr >= 'A' && chr <= 'Z') || (chr >= '0' && chr <= '9') {
continue // alphanumerics
}
switch chr {
case '$', '%', '^', '&', '(', ')', '{', '}', '[', ']', '<', '>', '=':
continue // benign printable ascii characters
default:
return false // potentially confusable ascii like | ' `, non-ascii
}
}
return true
}
var skeletonCasefolder = precis.NewIdentifier(precis.FoldWidth, precis.LowerCase(), precis.Norm(norm.NFC))
// similar to Casefold, but exempts the bidi rule, because skeletons may
// mix scripts strangely
func casefoldSkeleton(str string) (string, error) {
return iterateFolding(skeletonCasefolder, str)
}
// Skeleton produces a canonicalized identifier that tries to catch
// homoglyphic / confusable identifiers. It's a tweaked version of the TR39
// skeleton algorithm. We apply the skeleton algorithm first and only then casefold,
// because casefolding first would lose some information about visual confusability.
// This has the weird consequence that the skeleton is not a function of the
// casefolded identifier --- therefore it must always be computed
// from the original (unfolded) identifier and stored/tracked separately from the
// casefolded identifier.
func Skeleton(name string) (string, error) {
if !isBoring(name) {
name = confusables.Skeleton(name)
}
return casefoldSkeleton(name)
}

View File

@ -127,3 +127,50 @@ func TestCasefoldName(t *testing.T) {
}) })
} }
} }
func TestIsBoring(t *testing.T) {
assertBoring := func(str string, expected bool) {
if isBoring(str) != expected {
t.Errorf("expected [%s] to have boringness [%t], but got [%t]", str, expected, !expected)
}
}
assertBoring("warning", true)
assertBoring("phi|ip", false)
assertBoring("Νικηφόρος", false)
}
func TestSkeleton(t *testing.T) {
skeleton := func(str string) string {
skel, err := Skeleton(str)
if err != nil {
t.Error(err)
}
return skel
}
if skeleton("warning") == skeleton("waming") {
t.Errorf("Oragono shouldn't consider rn confusable with m")
}
if skeleton("Phi|ip") != "philip" {
t.Errorf("but we still consider pipe confusable with l")
}
if skeleton("") != "smt" {
t.Errorf("fullwidth characters should skeletonize to plain old ascii characters")
}
if skeleton("") != "smt" {
t.Errorf("after skeletonizing, we should casefold")
}
if skeleton("еvan") != "evan" {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
if skeleton("РОТАТО") != "potato" {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
}