migrations.go (7661B)
1 package database 2 3 import ( 4 "cerca/util" 5 "context" 6 "database/sql" 7 "encoding/base64" 8 "errors" 9 "fmt" 10 "github.com/matthewhartstonge/argon2" 11 "log" 12 "regexp" 13 "strconv" 14 ) 15 16 /* switched argon2 library to support 32 bit due to flaw in previous library. 17 * change occurred in commits: 18 68a689612547ff83225f9a2727cf0c14dfbf7ceb 19 27c6d5684b6b464b900889c4b8a4dbae232d6b68 20 21 migration of the password hashes from synacor's embedded salt format to 22 matthewartstonge's key-val embedded format 23 24 migration details: 25 26 the old format had the following default parameters: 27 * time = 1 28 * memory = 64MiB 29 * threads = 4 30 * keyLen = 32 31 * saltLen = 16 bytes 32 * hashLen = 32 bytes? 33 * argonVersion = 13? 34 35 36 the new format uses the following parameters: 37 * TimeCost: 3, 38 * MemoryCost: 64 * 1024, // 2^(16) (64MiB of RAM) 39 * Parallelism: 4, 40 * SaltLength: 16, // 16 * 8 = 128-bits 41 * HashLength: 32, // 32 * 8 = 256-bits 42 * Mode: ModeArgon2id, 43 * Version: Version13, 44 45 the diff: 46 * time was changed to 3 from 1 47 * the version may or may not be the same (0x13) 48 49 a regex for changing the values would be the following 50 old format example value: 51 $argon2id19$1,65536,4$111111111111111111111111111111111111111111111111111111111111111111 52 old format was also encoding the salt and hash, not in base64 but in a slightly custom format (see var `encoding`) 53 54 regex to grab values 55 \$argon2id19\$1,65536,4\$(\S{66}) 56 diff regex from old to new 57 $argon2id$v=19$m=65536,t=${1},p=4${passwordhash} 58 new format example value: 59 $argon2id$v=19$m=65536,t=3,p=4$222222222222222222222222222222222222222222222222222222222222222222 60 */ 61 62 func Migration20240116_PwhashChange(filepath string) (finalErr error) { 63 d := InitDB(filepath) 64 ed := util.Describe("pwhash migration") 65 66 // the encoding defined in the old hashing library for string representations 67 // https://github.com/synacor/argon2id/blob/18569dfc600ba1ba89278c3c4789ad81dcab5bfb/argon2id.go#L48 68 var encoding = base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding) 69 70 // indices of the capture groups, index 0 is the matched string 71 const ( 72 _ = iota 73 TIME_INDEX 74 SALT_INDEX 75 HASH_INDEX 76 ) 77 78 // regex to parse out: 79 // 1. time parameter 80 // 2. salt 81 // 3. hash 82 const oldArgonPattern = `^\$argon2id19\$(\d),65536,4\$(\S+)\$(\S+)$` 83 oldRegex, err := regexp.Compile(oldArgonPattern) 84 ed.Check(err, "failed to compile old argon encoding pattern") 85 // regex to confirm new records 86 const newArgonPattern = `^\$argon2id\$v=19\$m=65536,t=(\d),p=4\$(\S+)\$(\S+)$` 87 newRegex, err := regexp.Compile(newArgonPattern) 88 ed.Check(err, "failed to compile new argon encoding pattern") 89 90 // always perform migrations in a single transaction 91 tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) 92 rollbackOnErr := func(incomingErr error) bool { 93 if incomingErr != nil { 94 _ = tx.Rollback() 95 log.Println(incomingErr, "\nrolling back") 96 finalErr = incomingErr 97 return true 98 } 99 return false 100 } 101 102 // check table meta's schemaversion to see that it's empty (because i didn't set it initially X) 103 row := tx.QueryRow(`SELECT schemaversion FROM meta`) 104 placeholder := -1 105 err = row.Scan(&placeholder) 106 // we *want* to have no rows 107 if err != nil && !errors.Is(err, sql.ErrNoRows) { 108 if rollbackOnErr(err) { 109 return 110 } 111 } 112 // in this migration, we should *not* have any schemaversion set 113 if placeholder > 0 { 114 if rollbackOnErr(errors.New("schemaversion existed! there's a high likelihood that this migration has already been performed - exiting")) { 115 return 116 } 117 } 118 119 // alright onwards to the beesknees 120 // data struct to keep passwords and ids together - dont wanna mix things up now do we 121 type HashRecord struct { 122 id int 123 oldFormat string // the full encoded format of prev library. including salt and parameters, not just the hash 124 newFormat string // the full encoded format of new library. including salt and parameters, not just the hash 125 valid bool // only valid records will be updated (i.e. records whose format is confirmed by the the oldPattern regex) 126 } 127 128 var records []HashRecord 129 // get all password hashes and the id of their row 130 query := `SELECT id, passwordhash FROM users` 131 rows, err := tx.Query(query) 132 if rollbackOnErr(err) { 133 return 134 } 135 136 for rows.Next() { 137 var record HashRecord 138 err = rows.Scan(&record.id, &record.oldFormat) 139 if rollbackOnErr(err) { 140 return 141 } 142 if record.id == 0 { 143 if rollbackOnErr(errors.New("record id was not changed during scanning")) { 144 return 145 } 146 } 147 records = append(records, record) 148 } 149 150 // make the requisite pattern changes to the password hash 151 config := argon2.MemoryConstrainedDefaults() 152 for i := range records { 153 // parse out the time, salt, and hash from the old record format 154 matches := oldRegex.FindAllStringSubmatch(records[i].oldFormat, -1) 155 if len(matches) > 0 { 156 time, err := strconv.Atoi(matches[0][TIME_INDEX]) 157 rollbackOnErr(err) 158 salt := matches[0][SALT_INDEX] 159 hash := matches[0][HASH_INDEX] 160 161 // decode the old format's had a custom encoding t 162 // the correctly access the underlying buffers 163 saltBuf, err := encoding.DecodeString(salt) 164 util.Check(err, "decode salt using old format encoding") 165 hashBuf, err := encoding.DecodeString(hash) 166 util.Check(err, "decode hash using old format encoding") 167 168 config.TimeCost = uint32(time) // note this change, to match old time cost (necessary!) 169 raw := argon2.Raw{Config: config, Salt: saltBuf, Hash: hashBuf} 170 // this is what we will store in the database instead 171 newFormatEncoded := raw.Encode() 172 ok := newRegex.Match(newFormatEncoded) 173 if !ok { 174 if rollbackOnErr(errors.New("newly formed format doesn't match regex for new pattern")) { 175 return 176 } 177 } 178 records[i].newFormat = string(newFormatEncoded) 179 records[i].valid = true 180 } else { 181 // parsing the old format failed, let's check to see if this happens to be a new record 182 // (if it is, we'll just ignore it. but if it's not we error out of here) 183 ok := newRegex.MatchString(records[i].oldFormat) 184 if !ok { 185 // can't parse with regex matching old format or the new format 186 if rollbackOnErr(errors.New(fmt.Sprintf("unknown record format: %s", records[i].oldFormat))) { 187 return 188 } 189 } 190 } 191 } 192 193 fmt.Println(records) 194 195 fmt.Println("parsed and re-encoded all valid records from the old to the new format. proceeding to update database records") 196 for _, record := range records { 197 if !record.valid { 198 continue 199 } 200 // update each row with the password hash in the new format 201 stmt, err := tx.Prepare("UPDATE users SET passwordhash = ? WHERE id = ?") 202 defer stmt.Close() 203 _, err = stmt.Exec(record.newFormat, record.id) 204 if rollbackOnErr(err) { 205 return 206 } 207 } 208 fmt.Println("all records were updated without any error") 209 // when everything is done and dudsted insert schemaversion and set its value to 1 210 // _, err = tx.Exec(`INSERT INTO meta (schemaversion) VALUES (1)`) 211 // if rollbackOnErr(err) { 212 // return 213 // } 214 _ = tx.Commit() 215 return 216 } 217 218 func Migration20240720_ThreadPrivateChange(filepath string) (finalErr error) { 219 d := InitDB(filepath) 220 221 // always perform migrations in a single transaction 222 tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) 223 rollbackOnErr := func(incomingErr error) bool { 224 if incomingErr != nil { 225 _ = tx.Rollback() 226 log.Println(incomingErr, "\nrolling back") 227 finalErr = incomingErr 228 return true 229 } 230 return false 231 } 232 233 stmt := `ALTER TABLE threads 234 ADD COLUMN private INTEGER NOT NULL DEFAULT 0 235 ` 236 237 _, err = tx.Exec(stmt) 238 if err != nil { 239 if rollbackOnErr(err) { 240 return 241 } 242 } 243 244 _ = tx.Commit() 245 246 return nil 247 }