diff --git a/go.mod b/go.mod index 892eee6..a60a2d0 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,13 @@ module git.gnous.eu/gnouseu/plakken go 1.22 -require github.com/redis/go-redis/v9 v9.5.1 +require ( + github.com/redis/go-redis/v9 v9.5.1 + golang.org/x/crypto v0.20.0 +) require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + golang.org/x/sys v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index a341487..899f6d8 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,7 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/constant/constants.go b/internal/constant/constants.go index b99a4ce..ae9e4d5 100644 --- a/internal/constant/constants.go +++ b/internal/constant/constants.go @@ -5,4 +5,10 @@ import "time" const ( HTTPTimeout = 3 * time.Second ExpirationCurlCreate = 604800 * time.Second // Second in one week + TokenLength = 32 + ArgonSaltSize = 16 + ArgonMemory = 64 * 1024 + ArgonThreads = 4 + ArgonKeyLength = 32 + ArgonIterations = 2 ) diff --git a/internal/database/db.go b/internal/database/db.go index 6f1e6fc..8c5ec68 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -5,6 +5,7 @@ import ( "log" "time" + "git.gnous.eu/gnouseu/plakken/internal/secret" "github.com/redis/go-redis/v9" ) @@ -36,7 +37,7 @@ func ConnectDB(dbConfig *redis.Options) *redis.Client { func Ping(db *redis.Client) error { status := db.Ping(ctx) if status.String() != "ping: PONG" { - return &PingError{} + return &pingError{} } return nil } @@ -68,6 +69,13 @@ func (config DBConfig) UrlExist(url string) bool { return config.DB.Exists(ctx, url).Val() == 1 } -func (config DBConfig) VerifySecret(url string, secret string) bool { - return secret == config.DB.HGet(ctx, url, "secret").Val() +func (config DBConfig) VerifySecret(url string, token string) (bool, error) { + storedHash := config.DB.HGet(ctx, url, "secret").Val() + + result, err := secret.VerifyPassword(token, storedHash) + if err != nil { + return false, err + } + + return result, nil } diff --git a/internal/database/error.go b/internal/database/error.go index 3985065..de0951e 100644 --- a/internal/database/error.go +++ b/internal/database/error.go @@ -1,7 +1,7 @@ package database -type PingError struct{} +type pingError struct{} -func (m *PingError) Error() string { +func (m *pingError) Error() string { return "Connection to redis not work" } diff --git a/internal/httpServer/server.go b/internal/httpServer/server.go index 38976dc..f17ea63 100644 --- a/internal/httpServer/server.go +++ b/internal/httpServer/server.go @@ -42,8 +42,8 @@ func (config ServerConfig) router() { http.Handle("GET /static/{file}", http.FileServer(staticFiles)) http.HandleFunc("GET /{key}/{settings...}", WebConfig.View) http.HandleFunc("POST /{$}", WebConfig.CurlCreate) - http.HandleFunc("POST /create/{$}", WebConfig.Create) - http.HandleFunc("DELETE /{key}", WebConfig.Delete) + http.HandleFunc("POST /create/{$}", WebConfig.PostCreate) + http.HandleFunc("DELETE /{key}", WebConfig.DeleteRequest) } // Config Configure HTTP server diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go new file mode 100644 index 0000000..957c260 --- /dev/null +++ b/internal/secret/crypto.go @@ -0,0 +1,147 @@ +// Package secret implement all crypto utils like password hashing and secret generation +package secret + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "strconv" + "strings" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "golang.org/x/crypto/argon2" +) + +type argon2idHash struct { + salt []byte + hash []byte +} + +// Argon2id config +type config struct { + saltLength uint8 + memory uint32 + threads uint8 + keyLength uint32 + iterations uint32 +} + +// generateSecret for password hashing or token generation +func generateSecret(length uint8) ([]byte, error) { + secret := make([]byte, length) + + _, err := rand.Read(secret) + if err != nil { + return nil, err + } + + return secret, err +} + +// GenerateToken generate hexadecimal token +func GenerateToken() (string, error) { + secret, err := generateSecret(constant.TokenLength) + if err != nil { + return "", err + } + + token := fmt.Sprintf("%x", secret) + + return token, nil +} + +// generateArgon2ID Generate an argon2id hash from source string and specified salt +func (config config) generateArgon2ID(source string, salt []byte) []byte { + hash := argon2.IDKey([]byte(source), salt, config.iterations, config.memory, config.threads, config.keyLength) + + return hash +} + +// Password hash a source string with argon2id, return properly encoded hash +func Password(password string) (string, error) { + config := config{ + saltLength: constant.ArgonSaltSize, + memory: constant.ArgonMemory, + threads: constant.ArgonThreads, + keyLength: constant.ArgonKeyLength, + iterations: constant.ArgonIterations, + } + + salt, err := generateSecret(config.saltLength) + if err != nil { + return "", err + } + + hash := config.generateArgon2ID(password, salt) + + base64Hash := base64.RawStdEncoding.EncodeToString(hash) + base64Salt := base64.RawStdEncoding.EncodeToString(salt) + + formatted := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, config.memory, config.iterations, config.threads, base64Salt, base64Hash) + + return formatted, nil +} + +// VerifyPassword check is source password and stored password is similar, take password and a properly encoded hash. +func VerifyPassword(password string, hash string) (bool, error) { + argon2Hash, config, err := parseHash(hash) + if err != nil { + return false, err + } + + result := config.generateArgon2ID(password, argon2Hash.salt) + + return bytes.Equal(result, argon2Hash.hash), nil +} + +// parseHash parse existing encoded argon2id string +func parseHash(source string) (argon2idHash, config, error) { + separateItem := strings.Split(source, "$") + if len(separateItem) != 6 { + return argon2idHash{}, config{}, &parseError{message: "Hash format is not valid"} + } + + if separateItem[1] != "argon2id" { + return argon2idHash{}, config{}, &parseError{message: "Algorithm is not valid"} + } + + separateParam := strings.Split(separateItem[3], ",") + if len(separateParam) != 3 { + return argon2idHash{}, config{}, &parseError{message: "Hash config is not valid"} + } + + salt, err := base64.RawStdEncoding.Strict().DecodeString(separateItem[4]) + if err != nil { + return argon2idHash{}, config{}, err + } + + var hash []byte + hash, err = base64.RawStdEncoding.Strict().DecodeString(separateItem[5]) + if err != nil { + return argon2idHash{}, config{}, err + } + + saltLength := uint8(len(salt)) + keyLength := uint32(len(hash)) + + var memory int + memory, err = strconv.Atoi(strings.Replace(separateParam[0], "m=", "", -1)) + if err != nil { + return argon2idHash{}, config{}, err + } + + var iterations int + iterations, err = strconv.Atoi(strings.Replace(separateParam[1], "t=", "", -1)) + if err != nil { + return argon2idHash{}, config{}, err + } + + var threads int + threads, err = strconv.Atoi(strings.Replace(separateParam[2], "p=", "", -1)) + if err != nil { + return argon2idHash{}, config{}, err + } + + return argon2idHash{salt: salt, hash: hash}, config{saltLength: saltLength, memory: uint32(memory), threads: uint8(threads), iterations: uint32(iterations), keyLength: keyLength}, nil +} diff --git a/internal/secret/error.go b/internal/secret/error.go new file mode 100644 index 0000000..2b82c3a --- /dev/null +++ b/internal/secret/error.go @@ -0,0 +1,9 @@ +package secret + +type parseError struct { + message string +} + +func (m *parseError) Error() string { + return "parseHash: " + m.message +} diff --git a/internal/utils/error.go b/internal/utils/error.go index d946d64..87acf33 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -1,17 +1,17 @@ package utils -type ParseIntBeforeSeparatorError struct { - Message string +type parseIntBeforeSeparatorError struct { + message string } -func (m *ParseIntBeforeSeparatorError) Error() string { - return "parseIntBeforeSeparator: " + m.Message +func (m *parseIntBeforeSeparatorError) Error() string { + return "parseIntBeforeSeparator: " + m.message } type ParseExpirationError struct { - Message string + message string } func (m *ParseExpirationError) Error() string { - return "parseIntBeforeSeparator: " + m.Message + return "parseIntBeforeSeparator: " + m.message } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e3a98b1..fc86af6 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,8 +1,6 @@ package utils import ( - "crypto/rand" - "encoding/hex" "log" mathrand "math/rand/v2" "regexp" @@ -21,17 +19,6 @@ func GenerateUrl(length uint8) string { return string(b) } -// GenerateSecret generate random secret (32 bytes hexadecimal) -func GenerateSecret() string { - key := make([]byte, 32) - _, err := rand.Read(key) - if err != nil { - log.Printf("Failed to generate secret") - } - - return hex.EncodeToString(key) -} - // CheckCharRedundant verify is a character is redundant in a string func CheckCharRedundant(source string, char string) bool { // Verify if a char is redundant return strings.Count(source, char) > 1 @@ -39,7 +26,7 @@ func CheckCharRedundant(source string, char string) bool { // Verify if a char i func parseIntBeforeSeparator(source *string, sep string) (int, error) { // return 0 & error if error, only accept positive number if CheckCharRedundant(*source, sep) { - return 0, &ParseIntBeforeSeparatorError{Message: *source + ": cannot parse value as int"} + return 0, &parseIntBeforeSeparatorError{message: *source + ": cannot parse value as int"} } var value int var err error @@ -47,14 +34,14 @@ func parseIntBeforeSeparator(source *string, sep string) (int, error) { // retur value, err = strconv.Atoi(strings.Split(*source, sep)[0]) if err != nil { log.Println(err) - return 0, &ParseIntBeforeSeparatorError{Message: *source + ": cannot parse value as int"} + return 0, &parseIntBeforeSeparatorError{message: *source + ": cannot parse value as int"} } if value < 0 { // Only positive value is correct - return 0, &ParseIntBeforeSeparatorError{Message: *source + ": format only take positive value"} + return 0, &parseIntBeforeSeparatorError{message: *source + ": format only take positive value"} } if value > 99 { - return 0, &ParseIntBeforeSeparatorError{Message: *source + ": Format only take two number"} + return 0, &parseIntBeforeSeparatorError{message: *source + ": Format only take two number"} } *source = strings.Join(strings.Split(*source, sep)[1:], "") @@ -77,25 +64,25 @@ func ParseExpiration(source string) (int, error) { expiration = tempOutput * 86400 if err != nil { log.Println(err) - return 0, &ParseExpirationError{Message: "Invalid syntax"} + return 0, &ParseExpirationError{message: "Invalid syntax"} } tempOutput, err = parseIntBeforeSeparator(&source, "h") expiration += tempOutput * 3600 if err != nil { log.Println(err) - return 0, &ParseExpirationError{Message: "Invalid syntax"} + return 0, &ParseExpirationError{message: "Invalid syntax"} } tempOutput, err = parseIntBeforeSeparator(&source, "m") expiration += tempOutput * 60 if err != nil { log.Println(err) - return 0, &ParseExpirationError{Message: "Invalid syntax"} + return 0, &ParseExpirationError{message: "Invalid syntax"} } tempOutput, err = parseIntBeforeSeparator(&source, "s") expiration += tempOutput * 1 if err != nil { log.Println(err) - return 0, &ParseExpirationError{Message: "Invalid syntax"} + return 0, &ParseExpirationError{message: "Invalid syntax"} } return expiration, nil diff --git a/internal/web/plak/error.go b/internal/web/plak/error.go index e2241f0..46c1fbf 100644 --- a/internal/web/plak/error.go +++ b/internal/web/plak/error.go @@ -1,10 +1,18 @@ package plak -type DeletePlakError struct { - Name string - Err error +type deletePlakError struct { + name string + err error } -func (m *DeletePlakError) Error() string { - return "Cannot delete: " + m.Name + " : " + m.Err.Error() +func (m *deletePlakError) Error() string { + return "Cannot delete: " + m.name + " : " + m.err.Error() +} + +type createError struct { + message string +} + +func (m *createError) Error() string { + return "create: cannot create plak: " + m.message } diff --git a/internal/web/plak/plak.go b/internal/web/plak/plak.go index b80c137..f65f0f5 100644 --- a/internal/web/plak/plak.go +++ b/internal/web/plak/plak.go @@ -10,6 +10,7 @@ import ( "git.gnous.eu/gnouseu/plakken/internal/constant" "git.gnous.eu/gnouseu/plakken/internal/database" + "git.gnous.eu/gnouseu/plakken/internal/secret" "git.gnous.eu/gnouseu/plakken/internal/utils" "github.com/redis/go-redis/v9" @@ -24,26 +25,48 @@ type WebConfig struct { Templates embed.FS } -// Plak "Object" for plak -type Plak struct { +// plak "Object" for plak +type plak struct { Key string Content string Expiration time.Duration DB *redis.Client } -// Create manage POST request for create Plak -func (config WebConfig) Create(w http.ResponseWriter, r *http.Request) { +// create a plak +func (plak plak) create() (string, error) { + dbConf := database.DBConfig{ + DB: plak.DB, + } + + token, err := secret.GenerateToken() + if err != nil { + return "", err + } + + if dbConf.UrlExist(plak.Key) { + return "", &createError{message: "key already exist"} + } + + var hashedSecret string + hashedSecret, err = secret.Password(token) + if err != nil { + return "", err + } + + dbConf.InsertPaste(plak.Key, plak.Content, hashedSecret, plak.Expiration) + + return token, nil +} + +// PostCreate manage POST request for create plak +func (config WebConfig) PostCreate(w http.ResponseWriter, r *http.Request) { content := r.FormValue("content") if content == "" { w.WriteHeader(http.StatusBadRequest) return } - dbConf := database.DBConfig{ - DB: config.DB, - } - filename := r.FormValue("filename") var key string if len(filename) == 0 { @@ -53,46 +76,67 @@ func (config WebConfig) Create(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - if dbConf.UrlExist(filename) { - w.WriteHeader(http.StatusBadRequest) - return - } key = filename } - secret := utils.GenerateSecret() - rawExpiration := r.FormValue("exp") + plak := plak{ + Key: key, + Content: content, + DB: config.DB, + } + rawExpiration := r.FormValue("exp") expiration, err := utils.ParseExpiration(rawExpiration) if err != nil { - w.WriteHeader(http.StatusBadRequest) + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) return - } else if expiration == 0 { - dbConf.InsertPaste(key, content, secret, -1) + } + + if expiration == 0 { + plak.Expiration = -1 } else { - dbConf.InsertPaste(key, content, secret, time.Duration(expiration*int(time.Second))) + plak.Expiration = time.Duration(expiration * int(time.Second)) + } + + _, err = plak.create() + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return } http.Redirect(w, r, "/"+key, http.StatusSeeOther) } -// CurlCreate Create plak with minimum param, ideal for curl. Force 7 day expiration +// CurlCreate PostCreate plak with minimum param, ideal for curl. Force 7 day expiration func (config WebConfig) CurlCreate(w http.ResponseWriter, r *http.Request) { if r.ContentLength == 0 { w.WriteHeader(http.StatusBadRequest) return } + content, _ := io.ReadAll(r.Body) err := r.Body.Close() if err != nil { log.Println(err) } + key := utils.GenerateUrl(config.UrlLength) - secret := utils.GenerateSecret() - dbConf := database.DBConfig{ - DB: config.DB, + + plak := plak{ + Key: key, + Content: string(content), + Expiration: constant.ExpirationCurlCreate, + DB: config.DB, + } + + var token string + token, err = plak.create() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return } - dbConf.InsertPaste(key, string(content), secret, constant.ExpirationCurlCreate) var baseURL string if r.TLS == nil { @@ -101,7 +145,7 @@ func (config WebConfig) CurlCreate(w http.ResponseWriter, r *http.Request) { baseURL = "https://" + r.Host + "/" + key } - message := baseURL + "\n" + "Delete with : 'curl -X DELETE " + baseURL + "?secret\\=" + secret + "'" + "\n" + message := baseURL + "\n" + "Delete with : 'curl -X DELETE " + baseURL + "?secret\\=" + token + "'" + "\n" w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, err = io.WriteString(w, message) @@ -115,18 +159,18 @@ func (config WebConfig) View(w http.ResponseWriter, r *http.Request) { dbConf := database.DBConfig{ DB: config.DB, } - var plak Plak + var currentPlak plak key := r.PathValue("key") if dbConf.UrlExist(key) { - plak = Plak{ + currentPlak = plak{ Key: key, DB: config.DB, } - plak = plak.GetContent() + currentPlak = currentPlak.getContent() if r.PathValue("settings") == "raw" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, err := io.WriteString(w, plak.Content) + _, err := io.WriteString(w, currentPlak.Content) if err != nil { log.Println(err) } @@ -135,10 +179,13 @@ func (config WebConfig) View(w http.ResponseWriter, r *http.Request) { if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Println(err) + return } - err = t.Execute(w, plak) + err = t.Execute(w, currentPlak) if err != nil { + w.WriteHeader(http.StatusInternalServerError) log.Println(err) + return } } } else { @@ -146,24 +193,35 @@ func (config WebConfig) View(w http.ResponseWriter, r *http.Request) { } } -// Delete manage plak deletion endpoint -func (config WebConfig) Delete(w http.ResponseWriter, r *http.Request) { +// DeleteRequest manage plak deletion endpoint +func (config WebConfig) DeleteRequest(w http.ResponseWriter, r *http.Request) { dbConf := database.DBConfig{ DB: config.DB, } key := r.PathValue("key") + var valid bool if dbConf.UrlExist(key) { - secret := r.URL.Query().Get("secret") - if dbConf.VerifySecret(key, secret) { - plak := Plak{ + var err error + token := r.URL.Query().Get("secret") + + valid, err = dbConf.VerifySecret(key, token) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + + if valid { + plak := plak{ Key: key, DB: config.DB, } - err := plak.deletePlak() + err := plak.delete() if err != nil { log.Println(err) } + w.WriteHeader(http.StatusNoContent) return } else { @@ -171,22 +229,23 @@ func (config WebConfig) Delete(w http.ResponseWriter, r *http.Request) { return } } + w.WriteHeader(http.StatusNotFound) } -// deletePlak Delete plak from database -func (plak Plak) deletePlak() error { +// delete DeleteRequest plak from database +func (plak plak) delete() error { err := plak.DB.Del(ctx, plak.Key).Err() if err != nil { log.Println(err) - return &DeletePlakError{Name: plak.Key, Err: err} + return &deletePlakError{name: plak.Key, err: err} } return nil } -// GetContent get plak content -func (plak Plak) GetContent() Plak { +// getContent get plak content +func (plak plak) getContent() plak { plak.Content = plak.DB.HGet(ctx, plak.Key, "content").Val() return plak } diff --git a/test/secret/secret_test.go b/test/secret/secret_test.go new file mode 100644 index 0000000..fad6ba6 --- /dev/null +++ b/test/secret/secret_test.go @@ -0,0 +1,47 @@ +package secret_test + +import ( + "fmt" + "regexp" + "testing" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "git.gnous.eu/gnouseu/plakken/internal/secret" + "golang.org/x/crypto/argon2" +) + +func TestPasswordFormat(t *testing.T) { + regex := fmt.Sprintf("\\$argon2id\\$v=%d\\$m=%d,t=%d,p=%d\\$[A-Za-z0-9+/]*\\$[A-Za-z0-9+/]*$", argon2.Version, constant.ArgonMemory, constant.ArgonIterations, constant.ArgonThreads) + + got, err := secret.Password("Password!") + if err != nil { + t.Fatal(err) + } + + result, _ := regexp.MatchString(regex, got) + if !result { + t.Fatal("Error in Password, format is not valid "+": ", got) + } +} + +func TestVerifyPassword(t *testing.T) { + result, err := secret.VerifyPassword("Password!", "$argon2id$v=19$m=65536,t=2,p=4$A+t5YGpyy1BHCbvk/LP1xQ$eNuUj6B2ZqXlGi6KEqep39a7N4nysUIojuPXye+Ypp0") + if err != nil { + t.Fatal(err) + } + + if !result { + t.Fatal("Error in VerifyPassword, got:", result, "want: ", true) + } +} + +func TestVerifyPasswordInvalid(t *testing.T) { + result, err := secret.VerifyPassword("notsamepassword", "$argon2id$v=19$m=65536,t=2,p=4$A+t5YGpyy1BHCbvk/LP1xQ$eNuUj6B2ZqXlGi6KEqep39a7N4nysUIojuPXye+Ypp0") + if err != nil { + t.Fatal(err) + } + + if result { + t.Fatal("Error in VerifyPassword, got:", result, "want: ", false) + } +}