cerca

lean forum software (pmc local branch)
git clone http://git.permacomputing.net/repos/cerca.git # read-only access
Log | Files | Refs | README | LICENSE

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 }