cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit 2bf6b9d5437af86f07a413bb71ed5875874d8b07
parent 15edbb88f4b28fb77bffae22056464f538285224
Author: cblgh <cblgh@cblgh.org>
Date:   Tue, 16 Jan 2024 20:01:31 +0100

add pw hash migration and migration tool

* MIGRATIONS.MD for the details and how to run the migration
* cmd/migration-tool for the tool
* database/migrationsgo for the code

Diffstat:
AMIGRATIONS.md | 30++++++++++++++++++++++++++++++
Acmd/migration-tool/main.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrypto/crypto.go | 36------------------------------------
Adatabase/migrations.go | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdatabase/moderation.go | 4++--
Mserver/server.go | 44++++++++++++++++++++++++--------------------
6 files changed, 338 insertions(+), 58 deletions(-)

diff --git a/MIGRATIONS.md b/MIGRATIONS.md @@ -0,0 +1,30 @@ +# Migrations + +This documents migrations for breaking database changes. These are intended to be as few as +possible, but sometimes they are necessary. + +## [2024-01-16] Migrating password hash libraries + +To support 32 bit architectures, such as running Cerca on an older Raspberry Pi, the password +hashing library used previously +[github.com/synacor/argon2id](https://github.com/synacor/argon2id) was swapped out for +[github.com/matthewhartstonge/argon2](https://github.com/matthewhartstonge/argon2). + +The password hashing algorithm remains the same, so users **do not** need to reset their +password, but the old encoding format needed migrating in the database. + +The new library also has a stronger default time parameter, which will be used for new +passwords while old passwords will use the time parameter stored together with their +database record. + +For more details, see [database/migrations.go](./database/migrations.go). + +Build and then run the migration tool in `cmd/migration-tool` accordingly: + +``` +cd cmd/migration-tool +go build +./migration-tool --database path-to-your-forum.db --migration 2024-01-password-hash-migration +``` + + diff --git a/cmd/migration-tool/main.go b/cmd/migration-tool/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "cerca/database" + "flag" + "fmt" + "os" +) + +func inform(msg string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf("%s\n", fmt.Sprintf(msg, args...)) + } else { + fmt.Printf("%s\n", msg) + } +} + +func complain(msg string, args ...interface{}) { + if len(args) > 0 { + inform(msg, args) + } else { + inform(msg) + } + os.Exit(0) +} + + +func main() { + migrations := map[string]func(string) error {"2024-01-password-hash-migration": database.Migration20240116_PwhashChange} + + var dbPath, migration string + var listMigrations bool + flag.BoolVar(&listMigrations, "list", false, "list possible migrations") + flag.StringVar(&migration, "migration", "", "name of the migration you want to perform on the database") + flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") + flag.Parse() + + usage := `usage + migration-tool --list + migration-tool --migration <name of migration> + ` + + if listMigrations { + inform("Possible migrations:") + for key := range migrations { + fmt.Println("\t", key) + } + os.Exit(0) + } + + if migration == "" { + complain(usage) + } else if _, ok := migrations[migration]; !ok { + complain(fmt.Sprintf("chosen migration »%s» does not match one of the available migrations. see migrations with flag --list", migration)) + } + + // check if database exists! we dont wanna create a new db in this case ':) + if !database.CheckExists(dbPath) { + complain("couldn't find database at %s", dbPath) + } + + // perform migration + err := migrations[migration](dbPath) + if err == nil { + inform(fmt.Sprintf("Migration »%s» completed", migration)) + } else { + complain("migration terminated early due to error") + } +} diff --git a/crypto/crypto.go b/crypto/crypto.go @@ -4,7 +4,6 @@ import ( "cerca/util" crand "crypto/rand" "encoding/binary" - // "github.com/synacor/argon2id" "github.com/matthewhartstonge/argon2" "math/big" rand "math/rand" @@ -21,41 +20,6 @@ func HashPassword(s string) (string, error) { return string(hash), nil } -// TODO (2023-12-05): figure out migration of the password hashes from synacor's embedded salt format to -// matthewartstonge's key-val embedded format - -// migration details: -// -// the old format had the following default parameters: -// * time = 1 -// * memory = 64MiB -// * threads = 4 -// * keyLen = 32 -// * saltLen = 16 bytes -// * hashLen = 32 bytes? -// * argonVersion = 13? -// -// -// the new format uses the following parameters: -// * HashLength: 32, // 32 * 8 = 256-bits -// * SaltLength: 16, // 16 * 8 = 128-bits -// * TimeCost: 3, -// * MemoryCost: 64 * 1024, // 2^(16) (64MiB of RAM) -// * Parallelism: 4, -// * Mode: ModeArgon2id, -// * Version: Version13, -// -// the diff: -// * time was changed to 3 from 1 -// * the version may or may not be the same (0x13) -// -// a regex for changing the values would be the following -// old format example value: -// $argon2id19$1,65536,4$111111111111111111111111111111111111111111111111111111111111111111 -// diff regex from old to new -// $argon2id$v=19$m=65536,t=${1},p=4${passwordhash} -// new format example value: -// $argon2id$v=19$m=65536,t=3,p=4$222222222222222222222222222222222222222222222222222222222222222222 func ValidatePasswordHash(password, passwordHash string) bool { ed := util.Describe("validate password hash") hashStruct, err := argon2.Decode([]byte(passwordHash)) diff --git a/database/migrations.go b/database/migrations.go @@ -0,0 +1,213 @@ +package database + +import ( + "encoding/base64" + "fmt" + "regexp" + "context" + "database/sql" + "strconv" + "log" + "errors" + "cerca/util" + "github.com/matthewhartstonge/argon2" +) +/* switched argon2 library to support 32 bit due to flaw in previous library. +* change occurred in commits: + 68a689612547ff83225f9a2727cf0c14dfbf7ceb + 27c6d5684b6b464b900889c4b8a4dbae232d6b68 + + migration of the password hashes from synacor's embedded salt format to + matthewartstonge's key-val embedded format + + migration details: + + the old format had the following default parameters: + * time = 1 + * memory = 64MiB + * threads = 4 + * keyLen = 32 + * saltLen = 16 bytes + * hashLen = 32 bytes? + * argonVersion = 13? + + + the new format uses the following parameters: + * TimeCost: 3, + * MemoryCost: 64 * 1024, // 2^(16) (64MiB of RAM) + * Parallelism: 4, + * SaltLength: 16, // 16 * 8 = 128-bits + * HashLength: 32, // 32 * 8 = 256-bits + * Mode: ModeArgon2id, + * Version: Version13, + + the diff: + * time was changed to 3 from 1 + * the version may or may not be the same (0x13) + + a regex for changing the values would be the following + old format example value: + $argon2id19$1,65536,4$111111111111111111111111111111111111111111111111111111111111111111 + old format was also encoding the salt and hash, not in base64 but in a slightly custom format (see var `encoding`) + + regex to grab values + \$argon2id19\$1,65536,4\$(\S{66}) + diff regex from old to new + $argon2id$v=19$m=65536,t=${1},p=4${passwordhash} + new format example value: + $argon2id$v=19$m=65536,t=3,p=4$222222222222222222222222222222222222222222222222222222222222222222 +*/ + +func Migration20240116_PwhashChange(filepath string) (finalErr error) { + d := InitDB(filepath) + ed := util.Describe("pwhash migration") + + // the encoding defined in the old hashing library for string representations + // https://github.com/synacor/argon2id/blob/18569dfc600ba1ba89278c3c4789ad81dcab5bfb/argon2id.go#L48 + var encoding = base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding) + + // indices of the capture groups, index 0 is the matched string + const ( + _ = iota + TIME_INDEX + SALT_INDEX + HASH_INDEX + ) + + // regex to parse out: + // 1. time parameter + // 2. salt + // 3. hash + const oldArgonPattern = `^\$argon2id19\$(\d),65536,4\$(\S+)\$(\S+)$` + oldRegex, err := regexp.Compile(oldArgonPattern) + ed.Check(err, "failed to compile old argon encoding pattern") + // regex to confirm new records + const newArgonPattern = `^\$argon2id\$v=19\$m=65536,t=(\d),p=4\$(\S+)\$(\S+)$` + newRegex, err := regexp.Compile(newArgonPattern) + ed.Check(err, "failed to compile new argon encoding pattern") + + // always perform migrations in a single transaction + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) + rollbackOnErr := func(incomingErr error) bool { + if incomingErr != nil { + _ = tx.Rollback() + log.Println(incomingErr, "\nrolling back") + finalErr = incomingErr + return true + } + return false + } + + // check table meta's schemaversion to see that it's empty (because i didn't set it initially X) + row := tx.QueryRow(`SELECT schemaversion FROM meta`) + placeholder := -1 + err = row.Scan(&placeholder) + // we *want* to have no rows + if err != nil && !errors.Is(err, sql.ErrNoRows) { + if rollbackOnErr(err) { return } + } + // in this migration, we should *not* have any schemaversion set + if placeholder > 0 { + if rollbackOnErr(errors.New("schemaversion existed! there's a high likelihood that this migration has already been performed - exiting")) { + return + } + } + + // alright onwards to the beesknees + // data struct to keep passwords and ids together - dont wanna mix things up now do we + type HashRecord struct { + id int + oldFormat string // the full encoded format of prev library. including salt and parameters, not just the hash + newFormat string // the full encoded format of new library. including salt and parameters, not just the hash + valid bool // only valid records will be updated (i.e. records whose format is confirmed by the the oldPattern regex) + } + + var records []HashRecord + // get all password hashes and the id of their row + query := `SELECT id, passwordhash FROM users` + rows, err := tx.Query(query) + if rollbackOnErr(err) { + return + } + + for rows.Next() { + var record HashRecord + err = rows.Scan(&record.id, &record.oldFormat) + if rollbackOnErr(err) { + return + } + if record.id == 0 { + if rollbackOnErr(errors.New("record id was not changed during scanning")) { + return + } + } + records = append(records, record) + } + + // make the requisite pattern changes to the password hash + config := argon2.MemoryConstrainedDefaults() + for i := range records { + // parse out the time, salt, and hash from the old record format + matches := oldRegex.FindAllStringSubmatch(records[i].oldFormat, -1) + if len(matches) > 0 { + time, err := strconv.Atoi(matches[0][TIME_INDEX]) + rollbackOnErr(err) + salt := matches[0][SALT_INDEX] + hash := matches[0][HASH_INDEX] + + // decode the old format's had a custom encoding t + // the correctly access the underlying buffers + saltBuf, err := encoding.DecodeString(salt) + util.Check(err, "decode salt using old format encoding") + hashBuf, err := encoding.DecodeString(hash) + util.Check(err, "decode hash using old format encoding") + + config.TimeCost = uint32(time) // note this change, to match old time cost (necessary!) + raw := argon2.Raw{Config:config, Salt: saltBuf, Hash: hashBuf} + // this is what we will store in the database instead + newFormatEncoded := raw.Encode() + ok := newRegex.Match(newFormatEncoded) + if !ok { + if rollbackOnErr(errors.New("newly formed format doesn't match regex for new pattern")) { + return + } + } + records[i].newFormat = string(newFormatEncoded) + records[i].valid = true + } else { + // parsing the old format failed, let's check to see if this happens to be a new record + // (if it is, we'll just ignore it. but if it's not we error out of here) + ok := newRegex.MatchString(records[i].oldFormat) + if !ok { + // can't parse with regex matching old format or the new format + if rollbackOnErr(errors.New(fmt.Sprintf("unknown record format: %s", records[i].oldFormat))) { + return + } + } + } + } + + fmt.Println(records) + + fmt.Println("parsed and re-encoded all valid records from the old to the new format. proceeding to update database records") + for _, record := range records { + if !record.valid { + continue + } + // update each row with the password hash in the new format + stmt, err := tx.Prepare("UPDATE users SET passwordhash = ? WHERE id = ?") + defer stmt.Close() + _, err = stmt.Exec(record.newFormat, record.id) + if rollbackOnErr(err) { + return + } + } + fmt.Println("all records were updated without any error") + // when everything is done and dudsted insert schemaversion and set its value to 1 + // _, err = tx.Exec(`INSERT INTO meta (schemaversion) VALUES (1)`) + // if rollbackOnErr(err) { + // return + // } + _ = tx.Commit() + return +} diff --git a/database/moderation.go b/database/moderation.go @@ -45,7 +45,7 @@ func (d DB) RemoveUser(userid int) (finalErr error) { _ = tx.Rollback() log.Println(incomingErr, "rolling back") finalErr = incomingErr - return + return } } rollbackOnErr(ed.Eout(err, "start transaction")) @@ -180,7 +180,7 @@ func (d DB) ProposeModerationAction(proposerid, recipientid, action int) (finalE _ = tx.Rollback() log.Println(incomingErr, "rolling back") finalErr = incomingErr - return + return } } diff --git a/server/server.go b/server/server.go @@ -609,28 +609,32 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request } username := req.PostFormValue("username") password := req.PostFormValue("password") - // read verification code from form - verificationLink := req.PostFormValue("verificationlink") - // fmt.Printf("user: %s, verilink: %s\n", username, verificationLink) - u, err := url.Parse(verificationLink) - if err != nil { - renderErr("Had troubles parsing the verification link, are you sure it was a proper url?") - return - } - // check verification link domain against allowlist - if !util.Contains(h.allowlist, u.Host) { - fmt.Println(h.allowlist, u.Host, util.Contains(h.allowlist, u.Host)) - renderErr("Verification link's host (%s) is not in the allowlist", u.Host) - return - } - - // parse out verification code from verification link and compare against verification code in session - has := hasVerificationCode(verificationLink, verificationCode) - if !has { - if !developing { - renderErr("Verification code from link (%s) does not match", verificationLink) + var verificationLink string + // skip verification code during dev registering + if !developing { + // read verification code from form + verificationLink = req.PostFormValue("verificationlink") + // fmt.Printf("user: %s, verilink: %s\n", username, verificationLink) + u, err := url.Parse(verificationLink) + if err != nil { + renderErr("Had troubles parsing the verification link, are you sure it was a proper url?") + return + } + // check verification link domain against allowlist + if !util.Contains(h.allowlist, u.Host) { + fmt.Println(h.allowlist, u.Host, util.Contains(h.allowlist, u.Host)) + renderErr("Verification link's host (%s) is not in the allowlist", u.Host) return } + + // parse out verification code from verification link and compare against verification code in session + has := hasVerificationCode(verificationLink, verificationCode) + if !has { + if !developing { + renderErr("Verification code from link (%s) does not match", verificationLink) + return + } + } } // make sure username is not registered already var exists bool