Browse Source

support migrating anope databases

tags/v2.4.0-rc1
Shivaram Lingamneni 3 years ago
parent
commit
82be9a8423

+ 165
- 0
distrib/anope/anope2json.py View File

@@ -0,0 +1,165 @@
1
+#!/usr/bin/python3
2
+
3
+import re
4
+import json
5
+import logging
6
+import sys
7
+from collections import defaultdict, namedtuple
8
+
9
+AnopeObject = namedtuple('AnopeObject', ('type', 'kv'))
10
+
11
+MASK_MAGIC_REGEX = re.compile(r'[*?!@]')
12
+
13
+def access_level_to_amode(level):
14
+    try:
15
+        level = int(level)
16
+    except:
17
+        return None
18
+    if level >= 10000:
19
+        return 'q'
20
+    elif level >= 9999:
21
+        return 'a'
22
+    elif level >= 5:
23
+        return 'o'
24
+    elif level >= 4:
25
+        return 'h'
26
+    elif level >= 3:
27
+        return 'v'
28
+    else:
29
+        return None
30
+
31
+def to_unixnano(timestamp):
32
+    return int(timestamp) * (10**9)
33
+
34
+def file_to_objects(infile):
35
+    result = []
36
+    obj = None
37
+    for line in infile:
38
+        pieces = line.rstrip('\r\n').split(' ', maxsplit=2)
39
+        if len(pieces) == 0:
40
+            logging.warning("skipping blank line in db")
41
+            continue
42
+        if pieces[0] == 'END':
43
+            result.append(obj)
44
+            obj = None
45
+        elif pieces[0] == 'OBJECT':
46
+            obj = AnopeObject(pieces[1], {})
47
+        elif pieces[0] == 'DATA':
48
+            obj.kv[pieces[1]] = pieces[2]
49
+        else:
50
+            raise ValueError("unknown command found in anope db", pieces[0])
51
+    return result
52
+
53
+ANOPE_MODENAME_TO_MODE = {
54
+    'NOEXTERNAL': 'n',
55
+    'TOPIC': 't',
56
+    'INVITE': 'i',
57
+    'NOCTCP': 'C',
58
+    'AUDITORIUM': 'u',
59
+    'SECRET': 's',
60
+}
61
+
62
+def convert(infile):
63
+    out = {
64
+        'version': 1,
65
+        'source': 'anope',
66
+        'users': defaultdict(dict),
67
+        'channels': defaultdict(dict),
68
+    }
69
+
70
+    objects = file_to_objects(infile)
71
+
72
+    lastmode_channels = set()
73
+
74
+    for obj in objects:
75
+        if obj.type == 'NickCore':
76
+            username = obj.kv['display']
77
+            userdata = {'name': username, 'hash': obj.kv['pass'], 'email': obj.kv['email']}
78
+            out['users'][username] = userdata
79
+        elif obj.type == 'NickAlias':
80
+            username = obj.kv['nc']
81
+            nick = obj.kv['nick']
82
+            userdata = out['users'][username]
83
+            if username.lower() == nick.lower():
84
+                userdata['registeredAt'] = to_unixnano(obj.kv['time_registered'])
85
+            else:
86
+                if 'additionalNicks' not in userdata:
87
+                    userdata['additionalNicks'] = []
88
+                userdata['additionalNicks'].append(nick)
89
+        elif obj.type == 'ChannelInfo':
90
+            chname = obj.kv['name']
91
+            founder = obj.kv['founder']
92
+            chdata = {
93
+                'name': chname,
94
+                'founder': founder,
95
+                'registeredAt': to_unixnano(obj.kv['time_registered']),
96
+                'topic': obj.kv['last_topic'],
97
+                'topicSetBy': obj.kv['last_topic_setter'],
98
+                'topicSetAt': to_unixnano(obj.kv['last_topic_time']),
99
+                'amode': {founder: 'q',}
100
+            }
101
+            # DATA last_modes INVITE KEY,hunter2 NOEXTERNAL REGISTERED TOPIC
102
+            last_modes = obj.kv.get('last_modes')
103
+            if last_modes:
104
+                modes = []
105
+                for mode_desc in last_modes.split():
106
+                    if ',' in mode_desc:
107
+                        mode_name, mode_value = mode_desc.split(',', maxsplit=1)
108
+                    else:
109
+                        mode_name, mode_value = mode_desc, None
110
+                    if mode_name == 'KEY':
111
+                        chdata['key'] = mode_value
112
+                    else:
113
+                        modes.append(ANOPE_MODENAME_TO_MODE.get(mode_name, ''))
114
+                chdata['modes'] = ''.join(modes)
115
+                # prevent subsequent ModeLock objects from modifying the mode list further:
116
+                lastmode_channels.add(chname)
117
+            out['channels'][chname] = chdata
118
+        elif obj.type == 'ModeLock':
119
+            if obj.kv.get('set') != '1':
120
+                continue
121
+            chname = obj.kv['ci']
122
+            if chname in lastmode_channels:
123
+                continue
124
+            chdata = out['channels'][chname]
125
+            modename = obj.kv['name']
126
+            if modename == 'KEY':
127
+                chdata['key'] = obj.kv['param']
128
+            else:
129
+                oragono_mode = ANOPE_MODENAME_TO_MODE.get(modename)
130
+                if oragono_mode is not None:
131
+                    stored_modes = chdata.get('modes', '')
132
+                    stored_modes += oragono_mode
133
+                    chdata['modes'] = stored_modes
134
+        elif obj.type == 'ChanAccess':
135
+            chname = obj.kv['ci']
136
+            target = obj.kv['mask']
137
+            mode = access_level_to_amode(obj.kv['data'])
138
+            if mode is None:
139
+                continue
140
+            if MASK_MAGIC_REGEX.search(target):
141
+                continue
142
+            chdata = out['channels'][chname]
143
+            amode = chdata.setdefault('amode', {})
144
+            amode[target] = mode
145
+            chdata['amode'] = amode
146
+
147
+    # do some basic integrity checks
148
+    for chname, chdata in out['channels'].items():
149
+        founder = chdata.get('founder')
150
+        if founder not in out['users']:
151
+            raise ValueError("no user corresponding to channel founder", chname, chdata.get('founder'))
152
+
153
+    return out
154
+
155
+def main():
156
+    if len(sys.argv) != 3:
157
+        raise Exception("Usage: anope2json.py anope.db output.json")
158
+    with open(sys.argv[1]) as infile:
159
+        output = convert(infile)
160
+        with open(sys.argv[2], 'w') as outfile:
161
+            json.dump(output, outfile)
162
+
163
+if __name__ == '__main__':
164
+    logging.basicConfig()
165
+    sys.exit(main())

+ 38
- 15
distrib/atheme/atheme2json.py View File

@@ -1,3 +1,5 @@
1
+#!/usr/bin/python3
2
+
1 3
 import json
2 4
 import logging
3 5
 import sys
@@ -6,6 +8,14 @@ from collections import defaultdict
6 8
 def to_unixnano(timestamp):
7 9
     return int(timestamp) * (10**9)
8 10
 
11
+# include/atheme/channels.h
12
+CMODE_FLAG_TO_MODE = {
13
+    0x001: 'i', # CMODE_INVITE
14
+    0x010: 'n', # CMODE_NOEXT
15
+    0x080: 's', # CMODE_SEC
16
+    0x100: 't', # CMODE_TOPIC
17
+}
18
+
9 19
 def convert(infile):
10 20
     out = {
11 21
         'version': 1,
@@ -17,8 +27,8 @@ def convert(infile):
17 27
     channel_to_founder = defaultdict(lambda: (None, None))
18 28
 
19 29
     for line in infile:
20
-        line = line.strip()
21
-        parts = line.split()
30
+        line = line.rstrip('\r\n')
31
+        parts = line.split(' ')
22 32
         category = parts[0]
23 33
         if category == 'MU':
24 34
             # user account
@@ -43,8 +53,23 @@ def convert(infile):
43 53
         elif category == 'MC':
44 54
             # channel registration
45 55
             # MC #mychannel 1600134478 1600467343 +v 272 0 0
56
+            # MC #NEWCHANNELTEST 1602270889 1602270974 +vg 1 0 0 jaeger4
46 57
             chname = parts[1]
47
-            out['channels'][chname].update({'name': chname, 'registeredAt': to_unixnano(parts[2])})
58
+            chdata = out['channels'][chname]
59
+            # XXX just give everyone +nt, regardless of lock status; they can fix it later
60
+            chdata.update({'name': chname, 'registeredAt': to_unixnano(parts[2])})
61
+            if parts[8] != '':
62
+                chdata['key'] = parts[8]
63
+            modes = {'n', 't'}
64
+            mlock_on, mlock_off = int(parts[5]), int(parts[6])
65
+            for flag, mode in CMODE_FLAG_TO_MODE.items():
66
+                if flag & mlock_on != 0:
67
+                    modes.add(mode)
68
+            for flag, mode in CMODE_FLAG_TO_MODE.items():
69
+                if flag & mlock_off != 0:
70
+                    modes.remove(mode)
71
+            chdata['modes'] = ''.join(modes)
72
+            chdata['limit'] = int(parts[7])
48 73
         elif category == 'MDC':
49 74
             # auxiliary data for a channel registration
50 75
             # MDC #mychannel private:topic:setter s
@@ -68,6 +93,7 @@ def convert(infile):
68 93
             set_at = int(parts[4])
69 94
             if 'amode' not in chdata:
70 95
                 chdata['amode'] = {}
96
+            # see libathemecore/flags.c: +o is op, +O is autoop, etc.
71 97
             if 'F' in flags:
72 98
                 # there can only be one founder
73 99
                 preexisting_founder, preexisting_set_at = channel_to_founder[chname]
@@ -75,15 +101,15 @@ def convert(infile):
75 101
                     chdata['founder'] = username
76 102
                     channel_to_founder[chname] = (username, set_at)
77 103
                 # but multiple people can receive the 'q' amode
78
-                chdata['amode'][username] = ord('q')
79
-            elif 'a' in flags:
80
-                chdata['amode'][username] = ord('a')
81
-            elif 'o' in flags:
82
-                chdata['amode'][username] = ord('o')
83
-            elif 'h' in flags:
84
-                chdata['amode'][username] = ord('h')
85
-            elif 'v' in flags:
86
-                chdata['amode'][username] = ord('v')
104
+                chdata['amode'][username] = 'q'
105
+            elif 'q' in flags:
106
+                chdata['amode'][username] = 'q'
107
+            elif 'o' in flags or 'O' in flags:
108
+                chdata['amode'][username] = 'o'
109
+            elif 'h' in flags or 'H' in flags:
110
+                chdata['amode'][username] = 'h'
111
+            elif 'v' in flags or 'V' in flags:
112
+                chdata['amode'][username] = 'v'
87 113
         else:
88 114
             pass
89 115
 
@@ -92,9 +118,6 @@ def convert(infile):
92 118
         founder = chdata.get('founder')
93 119
         if founder not in out['users']:
94 120
             raise ValueError("no user corresponding to channel founder", chname, chdata.get('founder'))
95
-        if 'registeredChannels' not in out['users'][founder]:
96
-            out['users'][founder]['registeredChannels'] = []
97
-        out['users'][founder]['registeredChannels'].append(chname)
98 121
 
99 122
     return out
100 123
 

+ 3
- 0
irc/accounts.go View File

@@ -1056,6 +1056,8 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou
1056 1056
 		}
1057 1057
 	case -1:
1058 1058
 		err = am.checkLegacyPassphrase(migrations.CheckAthemePassphrase, accountName, account.Credentials.PassphraseHash, passphrase)
1059
+	case -2:
1060
+		err = am.checkLegacyPassphrase(migrations.CheckAnopePassphrase, accountName, account.Credentials.PassphraseHash, passphrase)
1059 1061
 	default:
1060 1062
 		err = errAccountInvalidCredentials
1061 1063
 	}
@@ -1899,6 +1901,7 @@ const (
1899 1901
 	CredentialsSHA3Bcrypt CredentialsVersion = 1
1900 1902
 	// negative numbers for migration
1901 1903
 	CredentialsAtheme = -1
1904
+	CredentialsAnope  = -2
1902 1905
 )
1903 1906
 
1904 1907
 // AccountCredentials stores the various methods for verifying accounts.

+ 2
- 5
irc/channelreg.go View File

@@ -358,11 +358,8 @@ func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredCha
358 358
 
359 359
 	if includeFlags&IncludeModes != 0 {
360 360
 		tx.Set(fmt.Sprintf(keyChannelPassword, channelKey), channelInfo.Key, nil)
361
-		modeStrings := make([]string, len(channelInfo.Modes))
362
-		for i, mode := range channelInfo.Modes {
363
-			modeStrings[i] = string(mode)
364
-		}
365
-		tx.Set(fmt.Sprintf(keyChannelModes, channelKey), strings.Join(modeStrings, ""), nil)
361
+		modeString := modes.Modes(channelInfo.Modes).String()
362
+		tx.Set(fmt.Sprintf(keyChannelModes, channelKey), modeString, nil)
366 363
 		tx.Set(fmt.Sprintf(keyChannelUserLimit, channelKey), strconv.Itoa(channelInfo.UserLimit), nil)
367 364
 	}
368 365
 

+ 54
- 18
irc/import.go View File

@@ -9,7 +9,6 @@ import (
9 9
 	"io/ioutil"
10 10
 	"log"
11 11
 	"strconv"
12
-	"strings"
13 12
 
14 13
 	"github.com/tidwall/buntdb"
15 14
 
@@ -17,13 +16,12 @@ import (
17 16
 )
18 17
 
19 18
 type userImport struct {
20
-	Name               string
21
-	Hash               string
22
-	Email              string
23
-	RegisteredAt       int64 `json:"registeredAt"`
24
-	Vhost              string
25
-	AdditionalNicks    []string `json:"additionalNicks"`
26
-	RegisteredChannels []string
19
+	Name            string
20
+	Hash            string
21
+	Email           string
22
+	RegisteredAt    int64 `json:"registeredAt"`
23
+	Vhost           string
24
+	AdditionalNicks []string `json:"additionalNicks"`
27 25
 }
28 26
 
29 27
 type channelImport struct {
@@ -33,7 +31,10 @@ type channelImport struct {
33 31
 	Topic        string
34 32
 	TopicSetBy   string `json:"topicSetBy"`
35 33
 	TopicSetAt   int64  `json:"topicSetAt"`
36
-	Amode        map[string]int
34
+	Amode        map[string]string
35
+	Modes        string
36
+	Key          string
37
+	Limit        int
37 38
 }
38 39
 
39 40
 type databaseImport struct {
@@ -43,7 +44,23 @@ type databaseImport struct {
43 44
 	Channels map[string]channelImport
44 45
 }
45 46
 
46
-func doImportAthemeDB(config *Config, dbImport databaseImport, tx *buntdb.Tx) (err error) {
47
+func serializeAmodes(raw map[string]string) (result []byte, err error) {
48
+	processed := make(map[string]int, len(raw))
49
+	for accountName, mode := range raw {
50
+		if len(mode) != 1 {
51
+			return nil, fmt.Errorf("invalid mode %s for account %s", mode, accountName)
52
+		}
53
+		cfname, err := CasefoldName(accountName)
54
+		if err != nil {
55
+			return nil, fmt.Errorf("invalid amode recipient %s: %w", accountName, err)
56
+		}
57
+		processed[cfname] = int(mode[0])
58
+	}
59
+	result, err = json.Marshal(processed)
60
+	return
61
+}
62
+
63
+func doImportDBGeneric(config *Config, dbImport databaseImport, credsType CredentialsVersion, tx *buntdb.Tx) (err error) {
47 64
 	requiredVersion := 1
48 65
 	if dbImport.Version != requiredVersion {
49 66
 		return fmt.Errorf("unsupported version of the db for import: version %d is required", requiredVersion)
@@ -63,7 +80,7 @@ func doImportAthemeDB(config *Config, dbImport databaseImport, tx *buntdb.Tx) (e
63 80
 			continue
64 81
 		}
65 82
 		credentials := AccountCredentials{
66
-			Version:        CredentialsAtheme,
83
+			Version:        credsType,
67 84
 			PassphraseHash: []byte(userInfo.Hash),
68 85
 		}
69 86
 		marshaledCredentials, err := json.Marshal(&credentials)
@@ -83,9 +100,6 @@ func doImportAthemeDB(config *Config, dbImport databaseImport, tx *buntdb.Tx) (e
83 100
 		if len(userInfo.AdditionalNicks) != 0 {
84 101
 			tx.Set(fmt.Sprintf(keyAccountAdditionalNicks, cfUsername), marshalReservedNicks(userInfo.AdditionalNicks), nil)
85 102
 		}
86
-		if len(userInfo.RegisteredChannels) != 0 {
87
-			tx.Set(fmt.Sprintf(keyAccountChannels, cfUsername), strings.Join(userInfo.RegisteredChannels, ","), nil)
88
-		}
89 103
 	}
90 104
 
91 105
 	for chname, chInfo := range dbImport.Channels {
@@ -94,23 +108,43 @@ func doImportAthemeDB(config *Config, dbImport databaseImport, tx *buntdb.Tx) (e
94 108
 			log.Printf("invalid channel name %s: %v", chname, err)
95 109
 			continue
96 110
 		}
111
+		cffounder, err := CasefoldName(chInfo.Founder)
112
+		if err != nil {
113
+			log.Printf("invalid founder %s for channel %s: %v", chInfo.Founder, chname, err)
114
+			continue
115
+		}
97 116
 		tx.Set(fmt.Sprintf(keyChannelExists, cfchname), "1", nil)
98 117
 		tx.Set(fmt.Sprintf(keyChannelName, cfchname), chname, nil)
99 118
 		tx.Set(fmt.Sprintf(keyChannelRegTime, cfchname), strconv.FormatInt(chInfo.RegisteredAt, 10), nil)
100
-		tx.Set(fmt.Sprintf(keyChannelFounder, cfchname), chInfo.Founder, nil)
119
+		tx.Set(fmt.Sprintf(keyChannelFounder, cfchname), cffounder, nil)
120
+		accountChannelsKey := fmt.Sprintf(keyAccountChannels, cffounder)
121
+		founderChannels, fcErr := tx.Get(accountChannelsKey)
122
+		if fcErr != nil || founderChannels == "" {
123
+			founderChannels = cfchname
124
+		} else {
125
+			founderChannels = fmt.Sprintf("%s,%s", founderChannels, cfchname)
126
+		}
127
+		tx.Set(accountChannelsKey, founderChannels, nil)
101 128
 		if chInfo.Topic != "" {
102 129
 			tx.Set(fmt.Sprintf(keyChannelTopic, cfchname), chInfo.Topic, nil)
103 130
 			tx.Set(fmt.Sprintf(keyChannelTopicSetTime, cfchname), strconv.FormatInt(chInfo.TopicSetAt, 10), nil)
104 131
 			tx.Set(fmt.Sprintf(keyChannelTopicSetBy, cfchname), chInfo.TopicSetBy, nil)
105 132
 		}
106 133
 		if len(chInfo.Amode) != 0 {
107
-			m, err := json.Marshal(chInfo.Amode)
134
+			m, err := serializeAmodes(chInfo.Amode)
108 135
 			if err == nil {
109 136
 				tx.Set(fmt.Sprintf(keyChannelAccountToUMode, cfchname), string(m), nil)
110 137
 			} else {
111 138
 				log.Printf("couldn't serialize amodes for %s: %v", chname, err)
112 139
 			}
113 140
 		}
141
+		tx.Set(fmt.Sprintf(keyChannelModes, cfchname), chInfo.Modes, nil)
142
+		if chInfo.Key != "" {
143
+			tx.Set(fmt.Sprintf(keyChannelPassword, cfchname), chInfo.Key, nil)
144
+		}
145
+		if chInfo.Limit > 0 {
146
+			tx.Set(fmt.Sprintf(keyChannelUserLimit, cfchname), strconv.Itoa(chInfo.Limit), nil)
147
+		}
114 148
 	}
115 149
 
116 150
 	return nil
@@ -119,9 +153,11 @@ func doImportAthemeDB(config *Config, dbImport databaseImport, tx *buntdb.Tx) (e
119 153
 func doImportDB(config *Config, dbImport databaseImport, tx *buntdb.Tx) (err error) {
120 154
 	switch dbImport.Source {
121 155
 	case "atheme":
122
-		return doImportAthemeDB(config, dbImport, tx)
156
+		return doImportDBGeneric(config, dbImport, CredentialsAtheme, tx)
157
+	case "anope":
158
+		return doImportDBGeneric(config, dbImport, CredentialsAnope, tx)
123 159
 	default:
124
-		return fmt.Errorf("only imports from atheme are currently supported")
160
+		return fmt.Errorf("unsupported import source: %s", dbImport.Source)
125 161
 	}
126 162
 }
127 163
 

+ 100
- 2
irc/migrations/passwords.go View File

@@ -9,12 +9,14 @@ import (
9 9
 	"crypto/sha512"
10 10
 	"crypto/subtle"
11 11
 	"encoding/base64"
12
+	"encoding/binary"
12 13
 	"encoding/hex"
13 14
 	"errors"
14 15
 	"hash"
15 16
 	"strconv"
16 17
 
17 18
 	"github.com/GehirnInc/crypt/md5_crypt"
19
+	"golang.org/x/crypto/bcrypt"
18 20
 	"golang.org/x/crypto/pbkdf2"
19 21
 )
20 22
 
@@ -24,15 +26,18 @@ var (
24 26
 
25 27
 	hmacServerKeyText    = []byte("Server Key")
26 28
 	athemePBKDF2V2Prefix = []byte("$z")
29
+	athemeRawSHA1Prefix  = []byte("$rawsha1$")
27 30
 )
28 31
 
29 32
 type PassphraseCheck func(hash, passphrase []byte) (err error)
30 33
 
31 34
 func CheckAthemePassphrase(hash, passphrase []byte) (err error) {
32
-	if len(hash) < 60 {
33
-		return checkAthemePosixCrypt(hash, passphrase)
35
+	if bytes.HasPrefix(hash, athemeRawSHA1Prefix) {
36
+		return checkAthemeRawSha1(hash, passphrase)
34 37
 	} else if bytes.HasPrefix(hash, athemePBKDF2V2Prefix) {
35 38
 		return checkAthemePBKDF2V2(hash, passphrase)
39
+	} else if len(hash) < 60 {
40
+		return checkAthemePosixCrypt(hash, passphrase)
36 41
 	} else {
37 42
 		return checkAthemePBKDF2(hash, passphrase)
38 43
 	}
@@ -181,3 +186,96 @@ func checkAthemePBKDF2(hash, passphrase []byte) (err error) {
181 186
 		return ErrHashCheckFailed
182 187
 	}
183 188
 }
189
+
190
+func checkAthemeRawSha1(hash, passphrase []byte) (err error) {
191
+	return checkRawHash(hash[len(athemeRawSHA1Prefix):], passphrase, sha1.New())
192
+}
193
+
194
+func checkRawHash(expected, passphrase []byte, h hash.Hash) (err error) {
195
+	var rawExpected []byte
196
+	size := h.Size()
197
+	if len(expected) == 2*size {
198
+		rawExpected = make([]byte, h.Size())
199
+		_, err = hex.Decode(rawExpected, expected)
200
+		if err != nil {
201
+			return ErrHashInvalid
202
+		}
203
+	} else if len(expected) == size {
204
+		rawExpected = expected
205
+	} else {
206
+		return ErrHashInvalid
207
+	}
208
+
209
+	h.Write(passphrase)
210
+	hashedPassphrase := h.Sum(nil)
211
+	if subtle.ConstantTimeCompare(rawExpected, hashedPassphrase) == 1 {
212
+		return nil
213
+	} else {
214
+		return ErrHashCheckFailed
215
+	}
216
+}
217
+
218
+func checkAnopeEncSha256(hashBytes, ivBytes, passphrase []byte) (err error) {
219
+	if len(ivBytes) != 32 {
220
+		return ErrHashInvalid
221
+	}
222
+	// https://github.com/anope/anope/blob/2cf507ed662620d0b97c8484fbfbfa09265e86e1/modules/encryption/enc_sha256.cpp#L67
223
+	var iv [8]uint32
224
+	for i := 0; i < 8; i++ {
225
+		iv[i] = binary.BigEndian.Uint32(ivBytes[i*4 : (i+1)*4])
226
+	}
227
+	result := anopeSum256(passphrase, iv)
228
+	if subtle.ConstantTimeCompare(result[:], hashBytes) == 1 {
229
+		return nil
230
+	} else {
231
+		return ErrHashCheckFailed
232
+	}
233
+}
234
+
235
+func CheckAnopePassphrase(hash, passphrase []byte) (err error) {
236
+	pieces := bytes.Split(hash, []byte{':'})
237
+	if len(pieces) < 2 {
238
+		return ErrHashInvalid
239
+	}
240
+	switch string(pieces[0]) {
241
+	case "plain":
242
+		// base64, standard encoding
243
+		expectedPassphrase, err := base64.StdEncoding.DecodeString(string(pieces[1]))
244
+		if err != nil {
245
+			return ErrHashInvalid
246
+		}
247
+		if subtle.ConstantTimeCompare(passphrase, expectedPassphrase) == 1 {
248
+			return nil
249
+		} else {
250
+			return ErrHashCheckFailed
251
+		}
252
+	case "md5":
253
+		// raw MD5
254
+		return checkRawHash(pieces[1], passphrase, md5.New())
255
+	case "sha1":
256
+		// raw SHA-1
257
+		return checkRawHash(pieces[1], passphrase, sha1.New())
258
+	case "bcrypt":
259
+		if bcrypt.CompareHashAndPassword(pieces[1], passphrase) == nil {
260
+			return nil
261
+		} else {
262
+			return ErrHashCheckFailed
263
+		}
264
+	case "sha256":
265
+		// SHA-256 with an overridden IV
266
+		if len(pieces) != 3 {
267
+			return ErrHashInvalid
268
+		}
269
+		hashBytes, err := hex.DecodeString(string(pieces[1]))
270
+		if err != nil {
271
+			return ErrHashInvalid
272
+		}
273
+		ivBytes, err := hex.DecodeString(string(pieces[2]))
274
+		if err != nil {
275
+			return ErrHashInvalid
276
+		}
277
+		return checkAnopeEncSha256(hashBytes, ivBytes, passphrase)
278
+	default:
279
+		return ErrHashInvalid
280
+	}
281
+}

+ 142
- 0
irc/migrations/passwords_test.go View File

@@ -11,6 +11,7 @@ import (
11 11
 func TestAthemePassphrases(t *testing.T) {
12 12
 	var err error
13 13
 
14
+	// modules/crypto/crypt3-md5:
14 15
 	err = CheckAthemePassphrase([]byte("$1$hcspif$nCm4r3S14Me9ifsOPGuJT."), []byte("shivarampassphrase"))
15 16
 	if err != nil {
16 17
 		t.Errorf("failed to check passphrase: %v", err)
@@ -21,6 +22,16 @@ func TestAthemePassphrases(t *testing.T) {
21 22
 		t.Errorf("accepted invalid passphrase")
22 23
 	}
23 24
 
25
+	err = CheckAthemePassphrase([]byte("$1$diwesm$9MjapdOyhyC.2FdHzKMzK."), []byte("1Ss1GN4q-3e8SgIJblfQxw"))
26
+	if err != nil {
27
+		t.Errorf("failed to check passphrase: %v", err)
28
+	}
29
+	err = CheckAthemePassphrase([]byte("$1$hcspif$nCm4r3S14Me9ifsOPGuJT."), []byte("sh1varampassphrase"))
30
+	if err == nil {
31
+		t.Errorf("accepted invalid passphrase")
32
+	}
33
+
34
+	// modules/crypto/pbkdf2:
24 35
 	err = CheckAthemePassphrase([]byte("khMlbBBIFya2ihyN42abc3e768663e2c4fd0e0020e46292bf9fdf44e9a51d2a2e69509cb73b4b1bf9c1b6355a1fc9ea663fcd6da902287159494f15b905e5e651d6a60f2ec834598"), []byte("password"))
25 36
 	if err != nil {
26 37
 		t.Errorf("failed to check passphrase: %v", err)
@@ -31,6 +42,7 @@ func TestAthemePassphrases(t *testing.T) {
31 42
 		t.Errorf("accepted invalid passphrase")
32 43
 	}
33 44
 
45
+	// modules/crypto/pbkdf2v2:
34 46
 	err = CheckAthemePassphrase([]byte("$z$65$64000$1kz1I9YJPJ2gkJALbrpL2DoxRDhYPBOg60KNJMK/6do=$Cnfg6pYhBNrVXiaXYH46byrC+3HKet/XvYwvI1BvZbs=$m0hrT33gcF90n2TU3lm8tdm9V9XC4xEV13KsjuT38iY="), []byte("password"))
35 47
 	if err != nil {
36 48
 		t.Errorf("failed to check passphrase: %v", err)
@@ -40,6 +52,30 @@ func TestAthemePassphrases(t *testing.T) {
40 52
 	if err == nil {
41 53
 		t.Errorf("accepted invalid passphrase")
42 54
 	}
55
+
56
+	weirdHash := []byte("$z$6$64000$rWfIGzPY9qiIt7m5$VdFroDOlTQSLlFUJtpvlbp2i7sH3ZUndqwdnOvoDvt6b2AzLjaAK/lhSO/QaR2nA3Wm4ObHdl3WMW32NdtSMdw==")
57
+	err = CheckAthemePassphrase(weirdHash, []byte("pHQpwje5CjS3_Lx0RaeS7w"))
58
+	if err != nil {
59
+		t.Errorf("failed to check passphrase: %v", err)
60
+	}
61
+	err = CheckAthemePassphrase(weirdHash, []byte("pHQpwje5CjS3-Lx0RaeS7w"))
62
+	if err == nil {
63
+		t.Errorf("accepted invalid passphrase")
64
+	}
65
+}
66
+
67
+func TestAthemeRawSha1(t *testing.T) {
68
+	var err error
69
+
70
+	shivaramHash := []byte("$rawsha1$49fffa5543f21dd6effe88a79633e4073e36a828")
71
+	err = CheckAthemePassphrase(shivaramHash, []byte("shivarampassphrase"))
72
+	if err != nil {
73
+		t.Errorf("failed to check passphrase: %v", err)
74
+	}
75
+	err = CheckAthemePassphrase(shivaramHash, []byte("edpassphrase"))
76
+	if err == nil {
77
+		t.Errorf("accepted invalid passphrase")
78
+	}
43 79
 }
44 80
 
45 81
 func TestOragonoLegacyPassphrase(t *testing.T) {
@@ -70,3 +106,109 @@ func TestOragonoLegacyPassphrase(t *testing.T) {
70 106
 		t.Errorf("accepted invalid passphrase")
71 107
 	}
72 108
 }
109
+
110
+func TestAnopePassphraseRawSha1(t *testing.T) {
111
+	var err error
112
+	shivaramHash := []byte("sha1:49fffa5543f21dd6effe88a79633e4073e36a828")
113
+	err = CheckAnopePassphrase(shivaramHash, []byte("shivarampassphrase"))
114
+	if err != nil {
115
+		t.Errorf("failed to check passphrase: %v", err)
116
+	}
117
+	err = CheckAnopePassphrase(shivaramHash, []byte("edpassphrase"))
118
+	if err == nil {
119
+		t.Errorf("accepted invalid passphrase")
120
+	}
121
+
122
+	edHash := []byte("sha1:ea44e256819de972c25fef0aa277396067d6024f")
123
+	err = CheckAnopePassphrase(edHash, []byte("edpassphrase"))
124
+	if err != nil {
125
+		t.Errorf("failed to check passphrase: %v", err)
126
+	}
127
+	err = CheckAnopePassphrase(edHash, []byte("shivarampassphrase"))
128
+	if err == nil {
129
+		t.Errorf("accepted invalid passphrase")
130
+	}
131
+}
132
+
133
+func TestAnopePassphraseRawMd5(t *testing.T) {
134
+	var err error
135
+	shivaramHash := []byte("md5:ce4bd864f37ffaa1b871aef22eea82ff")
136
+	err = CheckAnopePassphrase(shivaramHash, []byte("shivarampassphrase"))
137
+	if err != nil {
138
+		t.Errorf("failed to check passphrase: %v", err)
139
+	}
140
+	err = CheckAnopePassphrase(shivaramHash, []byte("edpassphrase"))
141
+	if err == nil {
142
+		t.Errorf("accepted invalid passphrase")
143
+	}
144
+
145
+	edHash := []byte("md5:dbf8be80e8dccdd33915b482e4390426")
146
+	err = CheckAnopePassphrase(edHash, []byte("edpassphrase"))
147
+	if err != nil {
148
+		t.Errorf("failed to check passphrase: %v", err)
149
+	}
150
+	err = CheckAnopePassphrase(edHash, []byte("shivarampassphrase"))
151
+	if err == nil {
152
+		t.Errorf("accepted invalid passphrase")
153
+	}
154
+}
155
+
156
+func TestAnopePassphrasePlain(t *testing.T) {
157
+	var err error
158
+	// not actually a hash
159
+	weirdHash := []byte("plain:YVxzMC1fMmZ+ZjM0OEAhN2FzZGYxNDJAIyFhZmE=")
160
+	err = CheckAnopePassphrase(weirdHash, []byte("a\\s0-_2f~f348@!7asdf142@#!afa"))
161
+	if err != nil {
162
+		t.Errorf("failed to check passphrase: %v", err)
163
+	}
164
+	err = CheckAnopePassphrase(weirdHash, []byte("edpassphrase"))
165
+	if err == nil {
166
+		t.Errorf("accepted invalid passphrase")
167
+	}
168
+}
169
+
170
+func TestAnopePassphraseBcrypt(t *testing.T) {
171
+	var err error
172
+	shivaramHash := []byte("bcrypt:$2a$10$UyNgHyniPukGf/3A6vzBx.VMNfej0h4WzATg4ahKW2H86a0QLcVIK")
173
+	err = CheckAnopePassphrase(shivaramHash, []byte("shivarampassphrase"))
174
+	if err != nil {
175
+		t.Errorf("failed to check passphrase: %v", err)
176
+	}
177
+	err = CheckAnopePassphrase(shivaramHash, []byte("edpassphrase"))
178
+	if err == nil {
179
+		t.Errorf("accepted invalid passphrase")
180
+	}
181
+}
182
+
183
+func TestAnopePassphraseEncSha256(t *testing.T) {
184
+	var err error
185
+	shivaramHash := []byte("sha256:ff337943c8c4219cd330a3075a699492e0f8b1a823bb76af0129f1f117ba0630:60250c3053f7b34e35576fc5063b8b396fe7b9ab416842117991a8e027aa72f6")
186
+	err = CheckAnopePassphrase(shivaramHash, []byte("shivarampassphrase"))
187
+	if err != nil {
188
+		t.Errorf("failed to check passphrase: %v", err)
189
+	}
190
+	err = CheckAnopePassphrase(shivaramHash, []byte("edpassphrase"))
191
+	if err == nil {
192
+		t.Errorf("accepted invalid passphrase")
193
+	}
194
+
195
+	edHash := []byte("sha256:93a430c8c3c6917dc6e9a32ac1aba90bc5768265278a45b86eacd636fc723d8f:10ea72683a499c155d72cd3571cb80e5050280620f789a44492c0e0c7956942f")
196
+	err = CheckAnopePassphrase(edHash, []byte("edpassphrase"))
197
+	if err != nil {
198
+		t.Errorf("failed to check passphrase: %v", err)
199
+	}
200
+	err = CheckAnopePassphrase(edHash, []byte("shivarampassphrase"))
201
+	if err == nil {
202
+		t.Errorf("accepted invalid passphrase")
203
+	}
204
+
205
+	weirdHash := []byte("sha256:06d11a06025354e37a7ddf48913a1c9831ffab47d04e4c22a89fd7835abcb6cc:3137788c2749da0419bc9df320991d2d72495c7065da4f39004fd21710601409")
206
+	err = CheckAnopePassphrase(weirdHash, []byte("1Ss1GN4q-3e8SgIJblfQxw"))
207
+	if err != nil {
208
+		t.Errorf("failed to check passphrase: %v", err)
209
+	}
210
+	err = CheckAnopePassphrase(weirdHash, []byte("shivarampassphrase"))
211
+	if err == nil {
212
+		t.Errorf("accepted invalid passphrase")
213
+	}
214
+}

+ 128
- 0
irc/migrations/sha256.go View File

@@ -0,0 +1,128 @@
1
+/*
2
+Copyright (c) 2009 The Go Authors. All rights reserved.
3
+
4
+Redistribution and use in source and binary forms, with or without
5
+modification, are permitted provided that the following conditions are
6
+met:
7
+
8
+   * Redistributions of source code must retain the above copyright
9
+notice, this list of conditions and the following disclaimer.
10
+   * Redistributions in binary form must reproduce the above
11
+copyright notice, this list of conditions and the following disclaimer
12
+in the documentation and/or other materials provided with the
13
+distribution.
14
+   * Neither the name of Google Inc. nor the names of its
15
+contributors may be used to endorse or promote products derived from
16
+this software without specific prior written permission.
17
+
18
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
+*/
30
+
31
+// SHA256 implementation from golang/go, modified to accommodate anope's
32
+// password hashing scheme, which overrides the initialization vector
33
+// using the salt.
34
+
35
+package migrations
36
+
37
+import (
38
+	"encoding/binary"
39
+)
40
+
41
+// The size of a SHA256 checksum in bytes.
42
+const Size = 32
43
+
44
+const (
45
+	chunk = 64
46
+)
47
+
48
+// digest represents the partial evaluation of a checksum.
49
+type digest struct {
50
+	h   [8]uint32
51
+	x   [chunk]byte
52
+	nx  int
53
+	len uint64
54
+}
55
+
56
+func (d *digest) Write(p []byte) (nn int, err error) {
57
+	nn = len(p)
58
+	d.len += uint64(nn)
59
+	if d.nx > 0 {
60
+		n := copy(d.x[d.nx:], p)
61
+		d.nx += n
62
+		if d.nx == chunk {
63
+			sha256BlockGeneric(d, d.x[:])
64
+			d.nx = 0
65
+		}
66
+		p = p[n:]
67
+	}
68
+	if len(p) >= chunk {
69
+		n := len(p) &^ (chunk - 1)
70
+		sha256BlockGeneric(d, p[:n])
71
+		p = p[n:]
72
+	}
73
+	if len(p) > 0 {
74
+		d.nx = copy(d.x[:], p)
75
+	}
76
+	return
77
+}
78
+
79
+func (d *digest) Sum(in []byte) []byte {
80
+	// Make a copy of d so that caller can keep writing and summing.
81
+	d0 := *d
82
+	hash := d0.checkSum()
83
+	return append(in, hash[:]...)
84
+}
85
+
86
+func (d *digest) checkSum() [Size]byte {
87
+	len := d.len
88
+	// Padding. Add a 1 bit and 0 bits until 56 bytes mod 64.
89
+	var tmp [64]byte
90
+	tmp[0] = 0x80
91
+	if len%64 < 56 {
92
+		d.Write(tmp[0 : 56-len%64])
93
+	} else {
94
+		d.Write(tmp[0 : 64+56-len%64])
95
+	}
96
+
97
+	// Length in bits.
98
+	len <<= 3
99
+	binary.BigEndian.PutUint64(tmp[:], len)
100
+	d.Write(tmp[0:8])
101
+
102
+	if d.nx != 0 {
103
+		panic("d.nx != 0")
104
+	}
105
+
106
+	var digest [Size]byte
107
+
108
+	binary.BigEndian.PutUint32(digest[0:], d.h[0])
109
+	binary.BigEndian.PutUint32(digest[4:], d.h[1])
110
+	binary.BigEndian.PutUint32(digest[8:], d.h[2])
111
+	binary.BigEndian.PutUint32(digest[12:], d.h[3])
112
+	binary.BigEndian.PutUint32(digest[16:], d.h[4])
113
+	binary.BigEndian.PutUint32(digest[20:], d.h[5])
114
+	binary.BigEndian.PutUint32(digest[24:], d.h[6])
115
+	binary.BigEndian.PutUint32(digest[28:], d.h[7])
116
+
117
+	return digest
118
+}
119
+
120
+// Anope password hashing function: SHA-256 with an override for the IV
121
+// The actual SHA-256 IV for reference:
122
+// [8]uint32{0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19}
123
+func anopeSum256(data []byte, iv [8]uint32) [Size]byte {
124
+	var d digest
125
+	d.h = iv
126
+	d.Write(data)
127
+	return d.checkSum()
128
+}

+ 154
- 0
irc/migrations/sha256block.go View File

@@ -0,0 +1,154 @@
1
+/*
2
+Copyright (c) 2009 The Go Authors. All rights reserved.
3
+
4
+Redistribution and use in source and binary forms, with or without
5
+modification, are permitted provided that the following conditions are
6
+met:
7
+
8
+   * Redistributions of source code must retain the above copyright
9
+notice, this list of conditions and the following disclaimer.
10
+   * Redistributions in binary form must reproduce the above
11
+copyright notice, this list of conditions and the following disclaimer
12
+in the documentation and/or other materials provided with the
13
+distribution.
14
+   * Neither the name of Google Inc. nor the names of its
15
+contributors may be used to endorse or promote products derived from
16
+this software without specific prior written permission.
17
+
18
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
+*/
30
+
31
+// SHA256 block step.
32
+// In its own file so that a faster assembly or C version
33
+// can be substituted easily.
34
+
35
+package migrations
36
+
37
+import "math/bits"
38
+
39
+var _K = []uint32{
40
+	0x428a2f98,
41
+	0x71374491,
42
+	0xb5c0fbcf,
43
+	0xe9b5dba5,
44
+	0x3956c25b,
45
+	0x59f111f1,
46
+	0x923f82a4,
47
+	0xab1c5ed5,
48
+	0xd807aa98,
49
+	0x12835b01,
50
+	0x243185be,
51
+	0x550c7dc3,
52
+	0x72be5d74,
53
+	0x80deb1fe,
54
+	0x9bdc06a7,
55
+	0xc19bf174,
56
+	0xe49b69c1,
57
+	0xefbe4786,
58
+	0x0fc19dc6,
59
+	0x240ca1cc,
60
+	0x2de92c6f,
61
+	0x4a7484aa,
62
+	0x5cb0a9dc,
63
+	0x76f988da,
64
+	0x983e5152,
65
+	0xa831c66d,
66
+	0xb00327c8,
67
+	0xbf597fc7,
68
+	0xc6e00bf3,
69
+	0xd5a79147,
70
+	0x06ca6351,
71
+	0x14292967,
72
+	0x27b70a85,
73
+	0x2e1b2138,
74
+	0x4d2c6dfc,
75
+	0x53380d13,
76
+	0x650a7354,
77
+	0x766a0abb,
78
+	0x81c2c92e,
79
+	0x92722c85,
80
+	0xa2bfe8a1,
81
+	0xa81a664b,
82
+	0xc24b8b70,
83
+	0xc76c51a3,
84
+	0xd192e819,
85
+	0xd6990624,
86
+	0xf40e3585,
87
+	0x106aa070,
88
+	0x19a4c116,
89
+	0x1e376c08,
90
+	0x2748774c,
91
+	0x34b0bcb5,
92
+	0x391c0cb3,
93
+	0x4ed8aa4a,
94
+	0x5b9cca4f,
95
+	0x682e6ff3,
96
+	0x748f82ee,
97
+	0x78a5636f,
98
+	0x84c87814,
99
+	0x8cc70208,
100
+	0x90befffa,
101
+	0xa4506ceb,
102
+	0xbef9a3f7,
103
+	0xc67178f2,
104
+}
105
+
106
+func sha256BlockGeneric(dig *digest, p []byte) {
107
+	var w [64]uint32
108
+	h0, h1, h2, h3, h4, h5, h6, h7 := dig.h[0], dig.h[1], dig.h[2], dig.h[3], dig.h[4], dig.h[5], dig.h[6], dig.h[7]
109
+	for len(p) >= chunk {
110
+		// Can interlace the computation of w with the
111
+		// rounds below if needed for speed.
112
+		for i := 0; i < 16; i++ {
113
+			j := i * 4
114
+			w[i] = uint32(p[j])<<24 | uint32(p[j+1])<<16 | uint32(p[j+2])<<8 | uint32(p[j+3])
115
+		}
116
+		for i := 16; i < 64; i++ {
117
+			v1 := w[i-2]
118
+			t1 := (bits.RotateLeft32(v1, -17)) ^ (bits.RotateLeft32(v1, -19)) ^ (v1 >> 10)
119
+			v2 := w[i-15]
120
+			t2 := (bits.RotateLeft32(v2, -7)) ^ (bits.RotateLeft32(v2, -18)) ^ (v2 >> 3)
121
+			w[i] = t1 + w[i-7] + t2 + w[i-16]
122
+		}
123
+
124
+		a, b, c, d, e, f, g, h := h0, h1, h2, h3, h4, h5, h6, h7
125
+
126
+		for i := 0; i < 64; i++ {
127
+			t1 := h + ((bits.RotateLeft32(e, -6)) ^ (bits.RotateLeft32(e, -11)) ^ (bits.RotateLeft32(e, -25))) + ((e & f) ^ (^e & g)) + _K[i] + w[i]
128
+
129
+			t2 := ((bits.RotateLeft32(a, -2)) ^ (bits.RotateLeft32(a, -13)) ^ (bits.RotateLeft32(a, -22))) + ((a & b) ^ (a & c) ^ (b & c))
130
+
131
+			h = g
132
+			g = f
133
+			f = e
134
+			e = d + t1
135
+			d = c
136
+			c = b
137
+			b = a
138
+			a = t1 + t2
139
+		}
140
+
141
+		h0 += a
142
+		h1 += b
143
+		h2 += c
144
+		h3 += d
145
+		h4 += e
146
+		h5 += f
147
+		h6 += g
148
+		h7 += h
149
+
150
+		p = p[chunk:]
151
+	}
152
+
153
+	dig.h[0], dig.h[1], dig.h[2], dig.h[3], dig.h[4], dig.h[5], dig.h[6], dig.h[7] = h0, h1, h2, h3, h4, h5, h6, h7
154
+}

+ 4
- 4
irc/modes/modes.go View File

@@ -89,11 +89,11 @@ func (changes ModeChanges) Strings() (result []string) {
89 89
 type Modes []Mode
90 90
 
91 91
 func (modes Modes) String() string {
92
-	strs := make([]string, len(modes))
93
-	for index, mode := range modes {
94
-		strs[index] = mode.String()
92
+	var builder strings.Builder
93
+	for _, m := range modes {
94
+		builder.WriteRune(rune(m))
95 95
 	}
96
-	return strings.Join(strs, "")
96
+	return builder.String()
97 97
 }
98 98
 
99 99
 // User Modes

Loading…
Cancel
Save