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:
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