diff --git a/crypto.go b/crypto.go index b63623e8..f9934ff6 100644 --- a/crypto.go +++ b/crypto.go @@ -33,6 +33,7 @@ import ( "strings" "github.com/google/uuid" + argon2_lib "golang.org/x/crypto/argon2" bcrypt_lib "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/scrypt" ) @@ -66,6 +67,44 @@ func bcrypt(input string) string { return string(hash) } +func argon2id(input string, params ...uint32) string { + // RFC 9106 + // SECOND RECOMMENDED option and is suggested as a default setting for memory-constrained environments. + // + time := uint32(3) + memory := uint32(64 * 1024) + parallelism := uint8(1) + saltLen := uint32(16) + hashLen := uint32(32) + + if len(params) > 0 && params[0] != 0 { + time = params[0] + } + if len(params) > 1 && params[1] != 0 { + memory = params[1] + } + if len(params) > 2 && params[2] != 0 { + parallelism = uint8(params[2]) + } + if len(params) > 3 && params[3] != 0 { + saltLen = params[3] + } + if len(params) > 4 && params[4] != 0 { + hashLen = params[4] + } + + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return fmt.Sprintf("failed to generate argon2 salt with len %d: %s", saltLen, err) + } + + key := argon2_lib.IDKey([]byte(input), salt, time, memory, parallelism, hashLen) + saltKey := base64.RawStdEncoding.EncodeToString([]byte(salt)) + hashKey := base64.RawStdEncoding.EncodeToString(key) + format := "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s" + return fmt.Sprintf(format, argon2_lib.Version, memory, time, parallelism, saltKey, hashKey) +} + func hashSha(password string) string { s := sha1.New() s.Write([]byte(password)) diff --git a/crypto_test.go b/crypto_test.go index fc34ee0a..13e615ec 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -1,14 +1,17 @@ package sprig import ( + "bytes" "crypto/x509" "encoding/base64" "encoding/pem" + "errors" "fmt" "strings" "testing" "github.com/stretchr/testify/assert" + argon2_lib "golang.org/x/crypto/argon2" bcrypt_lib "golang.org/x/crypto/bcrypt" ) @@ -64,6 +67,190 @@ func TestBcrypt(t *testing.T) { } } +type Argon2idParameters struct { + Password string + Time uint32 + Memory uint32 + Parallelism uint32 + SaltLen uint32 + HashLen uint32 +} + +func TestArgon2idParameters(t *testing.T) { + expectations := []Argon2idParameters{ + { + Password: "abc", + Time: 1, + Memory: 131072, + Parallelism: 1, + SaltLen: 16, + HashLen: 32, + }, + { + Password: "abcefgh", + Time: 1, + Memory: 262144, + Parallelism: 4, + SaltLen: 32, + HashLen: 64, + }, + { + Password: "abcd", + Time: 0, + Memory: 0, + Parallelism: 0, + SaltLen: 0, + HashLen: 0, + }, + } + + for i, param := range expectations { + hash, err := runRaw( + `{{argon2id .Password .Time .Memory .Parallelism .SaltLen .HashLen}}`, + param, + ) + if err != nil { + t.Errorf("Test %d: failed to render template: %s", i, err) + continue + } + + if err := argon2idVerify(hash, param); err != nil { + t.Errorf("Test %d: Generated hash %s for password %s is not valid: %s", i, hash, param.Password, err) + } + } + + hash, err := runRaw(`{{argon2id "testPassword"}}`, nil) + if err != nil { + t.Errorf("failed to render template: %s", err) + return + } + + param := Argon2idParameters{ + Password: "testPassword", + Time: 3, + Memory: 64 * 1024, + Parallelism: 1, + SaltLen: 16, + HashLen: 32, + } + + if err := argon2idVerify(hash, param); err != nil { + t.Errorf("Generated hash %s for password %s is not valid: %s", hash, param.Password, err) + return + } + + hash2, err := runRaw(`{{argon2id "myPassword" 1 2097152 4}}`, nil) + if err != nil { + t.Errorf("failed to render template: %s", err) + return + } + + param2 := Argon2idParameters{ + Password: "myPassword", + Time: 1, + Memory: 2097152, + Parallelism: 4, + SaltLen: 16, + HashLen: 32, + } + + if err := argon2idVerify(hash2, param2); err != nil { + t.Errorf("Generated hash %s for password %s is not valid: %s", hash2, param2.Password, err) + } +} + +func argon2idVerify(encodedHash string, param Argon2idParameters) error { + hashParts := strings.Split(encodedHash, "$") + if len(hashParts) != 6 { + return errors.New("invalid hash format") + } + + if hashParts[1] != "argon2id" { + return fmt.Errorf("invalid variant: %s", hashParts[1]) + } + + var version int + if _, err := fmt.Sscanf(hashParts[2], "v=%d", &version); err != nil { + return fmt.Errorf("invalid version format: %s", err) + } + if version != argon2_lib.Version { + return fmt.Errorf("incompatible version: %d", version) + } + + memPart := strings.Split(hashParts[3], ",") + if len(memPart) != 3 { + return errors.New("invalid memory/time/parallelism format") + } + + var memory uint32 + if _, err := fmt.Sscanf(memPart[0], "m=%d", &memory); err != nil { + return fmt.Errorf("invalid memory value: %s", err) + } + + var timeVal uint32 + if _, err := fmt.Sscanf(memPart[1], "t=%d", &timeVal); err != nil { + return fmt.Errorf("invalid time value: %s", err) + } + + var parallelism uint8 + if _, err := fmt.Sscanf(memPart[2], "p=%d", ¶llelism); err != nil { + return fmt.Errorf("invalid parallelism value: %s", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(hashParts[4]) + if err != nil { + return fmt.Errorf("failed to decode salt: %s", err) + } + expectedHash, err := base64.RawStdEncoding.DecodeString(hashParts[5]) + if err != nil { + return fmt.Errorf("failed to decode hash: %s", err) + } + + paramTime := param.Time + if paramTime == 0 { + paramTime = 3 + } + paramMemory := param.Memory + if paramMemory == 0 { + paramMemory = 64 * 1024 + } + paramParallelism := uint8(param.Parallelism) + if paramParallelism == 0 { + paramParallelism = 1 + } + paramSaltLen := param.SaltLen + if paramSaltLen == 0 { + paramSaltLen = uint32(len(salt)) + } + paramHashLen := param.HashLen + if paramHashLen == 0 { + paramHashLen = uint32(len(expectedHash)) + } + + if memory != paramMemory { + return fmt.Errorf("memory mismatch: hash has %d, expected %d", memory, paramMemory) + } + if timeVal != paramTime { + return fmt.Errorf("time mismatch: hash has %d, expected %d", timeVal, paramTime) + } + if parallelism != paramParallelism { + return fmt.Errorf("parallelism mismatch: hash has %d, expected %d", parallelism, paramParallelism) + } + if uint32(len(salt)) != paramSaltLen { + return fmt.Errorf("salt length mismatch: hash has %d, expected %d", len(salt), paramSaltLen) + } + if uint32(len(expectedHash)) != paramHashLen { + return fmt.Errorf("hash length mismatch: hash has %d, expected %d", len(expectedHash), paramHashLen) + } + + computed := argon2_lib.IDKey([]byte(param.Password), salt, paramTime, paramMemory, paramParallelism, paramHashLen) + if !bytes.Equal(computed, expectedHash) { + return errors.New("password does not match hash") + } + + return nil +} + type HtpasswdCred struct { Username string Password string diff --git a/docs/crypto.md b/docs/crypto.md index 35dbaa9c..3f9044a3 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -47,6 +47,21 @@ The `bcrypt` function receives a string, and generates its `bcrypt` hash. bcrypt "myPassword" ``` +## argon2id + +The `argon2id` function takes a `password`, a `time`, a `memory`, a `parallelism`, a `saltLen`, and a `hashLen` and generates a `argon2id' hash. +Default values follows second recommended values by [RFC 9106](https://datatracker.ietf.org/doc/rfc9106/) (t=3 and 64 MiB memory) as a default setting for memory-constrained environments, parallelism is 1 saltLen is 16 and hashLen is 32. +Argon2 is the winner of [Password Hashing Competition](https://www.password-hashing.net/) 2015 and is current recommended hashing algorithm for passwords. + +``` +argon2id "myPassword" [time [memory [parallelism [saltLen [hashLen]]]]] +``` + +### RFC 9106 First recommended format (t=1 and 2 GiB memory) using 4 cores +``` +argon2id "myPassword" 1 2097152 4 +``` + ## htpasswd The `htpasswd` function takes a `username`, a `password`, and a `hashAlgorithm` and generates a `bcrypt` (recommended) or a base64 encoded and prefixed `sha` hash of the password. `hashAlgorithm` is optional and defaults to `bcrypt`. The result can be used for basic authentication on an [Apache HTTP Server](https://httpd.apache.org/docs/2.4/misc/password_encryptions.html#basic). diff --git a/functions.go b/functions.go index cda47d26..fbee3ba3 100644 --- a/functions.go +++ b/functions.go @@ -340,6 +340,7 @@ var genericMap = map[string]interface{}{ // Crypto: "bcrypt": bcrypt, + "argon2id": argon2id, "htpasswd": htpasswd, "genPrivateKey": generatePrivateKey, "derivePassword": derivePassword, diff --git a/go.mod b/go.mod index 2fdb8b89..44f0492a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Masterminds/sprig/v3 -go 1.23.0 +go 1.24.0 toolchain go1.24.4 @@ -14,7 +14,7 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/spf13/cast v1.9.2 github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.45.0 ) require ( @@ -22,6 +22,7 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.38.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9333cec1..a0d9c589 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,10 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=