From 26369fff3d8d397018a08ae044f36b723fc7d880 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 6 Mar 2020 12:38:02 +1100 Subject: [PATCH] [FEATURE] Support Argon2id password hasing and improved entropy (#679) * [FEATURE] Support Argon2id Passwords - Updated go module github.com/simia-tech/crypt - Added Argon2id support for file based authentication backend - Made it the default method - Made it so backwards compatibility with SHA512 exists - Force seeding of the random string generator used for salts to ensure they are all different - Added command params to the authelia hash-password command - Automatically remove {CRYPT} from hashes as they are updated - Automatically change hashes when they are updated to the configured algorithm - Made the hashing algorithm parameters completely configurable - Added reasonably comprehensive test suites - Updated docs - Updated config template * Adjust error output * Fix unit test * Add unit tests and argon2 version check * Fix new unit tests * Update docs, added tests * Implement configurable values and more comprehensive testing * Added cmd params to hash_password, updated docs, misc fixes * More detailed error for cmd, fixed a typo * Fixed cmd flag error, minor refactoring * Requested Changes and Minor refactoring * Increase entropy * Update docs for entropy changes * Refactor to reduce nesting and easier code maintenance * Cleanup Errors (uniformity for the function call) * Check salt length, fix docs * Add Base64 string validation for argon2id * Cleanup and Finalization - Moved RandomString function from ./internal/authentication/password_hash.go to ./internal/utils/strings.go - Added SplitStringToArrayOfStrings func that splits strings into an array with a fixed max string len - Fixed an error in validator that would allow a zero salt length - Added a test to verify the upstream crypt module supports our defined random salt chars - Updated docs - Removed unused "HashingAlgorithm" string type * Update crypt go mod, support argon2id key length and major refactor * Config Template Update, Final Tests * Use schema defaults for hash-password cmd * Iterations check * Docs requested changes * Test Coverage, suggested edits * Wording edit * Doc changes * Default sanity changes * Default sanity changes - docs * CI Sanity changes * Memory in MB --- cmd/authelia/main.go | 4 +- config.template.yml | 14 +- docs/configuration/authentication/file.md | 149 +++++++-- docs/deployment/supported-proxies/index.md | 8 +- docs/security/measures.md | 19 +- go.mod | 2 +- go.sum | 2 + internal/authentication/const.go | 19 ++ internal/authentication/file_user_provider.go | 40 ++- .../authentication/file_user_provider_test.go | 188 +++++++++-- internal/authentication/password_hash.go | 160 +++++++--- internal/authentication/password_hash_test.go | 296 +++++++++++++++++- internal/commands/hash.go | 41 ++- .../configuration/schema/authentication.go | 39 ++- .../configuration/validator/authentication.go | 57 ++++ .../validator/authentication_test.go | 112 ++++++- internal/utils/strings.go | 33 +- internal/utils/strings_test.go | 37 +++ 18 files changed, 1089 insertions(+), 131 deletions(-) create mode 100644 internal/utils/strings_test.go diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 2a0bde5ce..5dd849458 100644 --- a/cmd/authelia/main.go +++ b/cmd/authelia/main.go @@ -59,7 +59,7 @@ func startServer() { var userProvider authentication.UserProvider if config.AuthenticationBackend.File != nil { - userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File.Path) + userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) } else if config.AuthenticationBackend.Ldap != nil { userProvider = authentication.NewLDAPUserProvider(*config.AuthenticationBackend.Ldap) } else { @@ -120,7 +120,7 @@ func main() { }, } - rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd, commands.MigrateCmd) + rootCmd.AddCommand(versionCmd, commands.MigrateCmd, commands.HashPasswordCmd) rootCmd.AddCommand(commands.CertificatesCmd) if err := rootCmd.Execute(); err != nil { log.Fatal(err) diff --git a/config.template.yml b/config.template.yml index b5a8b2384..fd4dfd297 100644 --- a/config.template.yml +++ b/config.template.yml @@ -97,11 +97,21 @@ authentication_backend: # which is updated when users reset their passwords. # Therefore, this backend is meant to be used in a dev environment # and not in production since it prevents Authelia to be scaled to - # more than one instance. + # more than one instance. The options under password_options have sane + # defaults, and as it has security implications it is highly recommended + # you leave the default values. Before considering changing these settings + # please read the docs page below: + # https://docs.authelia.com/configuration/authentication/file.html#password-hash-algorithm-tuning # ## file: ## path: ./users_database.yml - + ## password_options: + ## algorithm: argon2id + ## iterations: 1 + ## key_length: 32 + ## salt_length: 16 + ## memory: ‭1048576‬ + ## parallelism: 8 # Access Control # # Access control is a list of rules defining the authorizations applied for one diff --git a/docs/configuration/authentication/file.md b/docs/configuration/authentication/file.md index af1be4845..8424b2972 100644 --- a/docs/configuration/authentication/file.md +++ b/docs/configuration/authentication/file.md @@ -18,59 +18,168 @@ file in the configuration file. authentication_backend: file: path: /var/lib/authelia/users.yml + password_hashing: + algorithm: argon2id + iterations: 1 + salt_length: 16 + parallelism: 8 + memory: 1024 + + +### Password hashing configuration settings + + #### algorithm + - Value Type: String + - Possible Value: `argon2id` and `sha512` + - Recommended: `argon2id` + - What it Does: Changes the hashing algorithm + + #### iterations + - Value Type: Int + - Possible Value: `1` or higher for argon2id and `1000` or higher for sha512 + (will automatically be set to `1000` on lower settings) + - Recommended: `1` for the `argon2id` algorithm and `50000` for `sha512` + - What it Does: Adjusts the number of times we run the password through the hashing algorithm + + #### key_length + - Value Type: Int + - Possible Value: `16` or higher. + - Recommended: `32` or higher. + - What it Does: Adjusts the length of the actual hash + + #### salt_length + - Value Type: Int + - Possible Value: between `2` and `16` + - Recommended: `16` + - What it Does: Adjusts the length of the random salt we add to the password, there + is no reason not to set this to 16 + + #### parallelism + - Value Type: Int + - Possible Value: `1` or higher + - Recommended: `8` or twice your CPU cores + - What it Does: Sets the number of threads used for hashing + + #### memory + - Value Type: Int + - Possible Value: at least `8` times the value of `parallelism` + - Recommended: `1024‬‬` (1GB) or as much RAM as you can afford to give to hashing + - What it Does: Sets the amount of RAM used in MB for hashing + + +#### Examples for specific systems + +These examples have been tested against a single system to make sure they roughly take +0.5 seconds each. Your results may vary depending on individual specification and +utilization, but they are a good guide to get started. You should however read +[How to choose the right parameters for Argon2]. + +| System |Iterations|Parallelism|Memory | +|:------------: |:--------:|:---------:|:-----:| +|Raspberry Pi 2 | 1 | 8 | 64 | +|Raspberry Pi 3 | 1 | 8 | 128 | +|Raspberry Pi 4 | 1 | 8 | 128 | +|Intel G5 i5 NUC| 1 | 8 | 1024 | ## Format - -The format of the file is as follows. +The format of the users file is as follows. users: john: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: john.doe@authelia.com groups: - admins - dev harry: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: harry.potter@authelia.com groups: [] bob: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: bob.dylan@authelia.com groups: - dev james: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: james.dean@authelia.com + This file should be set with read/write permissions as it could be updated by users resetting their passwords. - + ## Passwords -The file contains hash of passwords instead of plain text passwords for security reasons. +The file contains hashed passwords instead of plain text passwords for security reasons. -You can use authelia binary or docker image to generate the hash of any password. +You can use Authelia binary or docker image to generate the hash of any password. The +hash-password command has many tunable options, you can view them with the +`authelia hash-password --help` command. For example if you wanted to improve the entropy +you could generate a 16 byte salt and provide it with the `--salt` flag. +Example: `authelia hash-password --salt abcdefghijklhijl`. For argon2id the salt must +always be valid for base64 decoding (characters a through z, A through Z, 0 through 9, and +/). -For instance, with the docker image, just run +For instance to generate a hash with the docker image just run: $ docker run authelia/authelia:latest authelia hash-password yourpassword - $6$rounds=50000$BpLnfgDsc2WD8F2q$be7OyobnQ8K09dyDiGjY.cULh4yDePMh6CUMpLwF4WHTJmLcPE2ijM2ZsqZL.hVAANojEfDu3sU9u9uD7AeBJ/ + $ Password hash: $argon2id$v=19$m=65536$3oc26byQuSkQqksq$zM1QiTvVPrMfV6BVLs2t4gM+af5IN7euO0VB6+Q8ZFs +Full CLI Help Documentation: -## Password Hash Function +``` +Hash a password to be used in file-based users database. Default algorithm is argon2id. -The only supported hash function is salted sha512 determined by the prefix `$6$` as described -in this [wiki](https://en.wikipedia.org/wiki/Crypt_(C)) page. +Usage: + authelia hash-password [password] [flags] -Although not the best hash function, Salted SHA512 is a decent algorithm given the number of rounds is big -enough. It's not the best because the difficulty to crack the hash does not on the performance of the machine. -The best algorithm, [Argon2](https://en.wikipedia.org/wiki/Argon2) does though. It won the -[Password Hashing Competition](https://en.wikipedia.org/wiki/Password_Hashing_Competition) in 2015 and is now -considered the best hashing function. There is an open [issue](https://github.com/authelia/authelia/issues/577) -to add support for this hashing function. +Flags: + -h, --help help for hash-password + -i, --iterations int set the number of hashing iterations (default 1) + -k, --key-length int [argon2id] set the key length param (default 32) + -m, --memory int [argon2id] set the amount of memory param (in MB) (default 1024) + -p, --parallelism int [argon2id] set the parallelism param (default 8) + -s, --salt string set the salt string + -l, --salt-length int set the auto-generated salt length (default 16) + -z, --sha512 use sha512 as the algorithm (defaults iterations to 50000, change with -i) +``` + +## Password hash algorithm +The default hash algorithm is salted Argon2id version 19. Argon2id is currently considered +the best hashing algorithm, and in 2015 won the +[Password Hashing Competition](https://en.wikipedia.org/wiki/Password_Hashing_Competition). +It benefits from customizable parameters allowing the cost of computing a hash to scale +into the future which makes it harder to brute-force. Argon2id was implemented due to community +feedback as you can see in this closed [issue](https://github.com/authelia/authelia/issues/577). + +Additionally SHA512 is supported for backwards compatibility and user choice. While it's a reasonable +hash function given high enough iterations, as hardware gets better it has a higher chance of being +brute-forced. + +Hashes are identifiable as argon2id or SHA512 by their prefix of either `$argon2id$` and `$6$` +respectively, as described in this [wiki page](https://en.wikipedia.org/wiki/Crypt_(C)). + + ### Password hash algorithm tuning + + All algorithm tuning is supported for Argon2id. The only configuration variables that affect + SHA512 are iterations and salt length. The configuration variables are unique to the file + authentication provider, thus they all exist in a key under the file authentication configuration + key called `password_hashing`. We have set what are considered as sane and recommended defaults + to cater for a reasonable system, if you're unsure about which settings to tune, please see the + parameters above, or for a more in depth understanding see the referenced documentation. + + #### Argon2 Links + [How to choose the right parameters for Argon2] + + [How to choose the right parameters for Argon2](https://www.twelve21.io/how-to-choose-the-right-parameters-for-argon2/) + + [Go Documentation](https://godoc.org/golang.org/x/crypto/argon2) + + [IETF Draft](https://tools.ietf.org/id/draft-irtf-cfrg-argon2-09.html) + + +[How to choose the right parameters for Argon2]: https://www.twelve21.io/how-to-choose-the-right-parameters-for-argon2/ \ No newline at end of file diff --git a/docs/deployment/supported-proxies/index.md b/docs/deployment/supported-proxies/index.md index 41790709c..00fdca5b7 100644 --- a/docs/deployment/supported-proxies/index.md +++ b/docs/deployment/supported-proxies/index.md @@ -13,7 +13,7 @@ can find the documentation of the configuration required for every supported proxy. If you are not aware of the workflow of an authentication request, reading this -[documentation](./home/architecture) first is highly recommended. +[documentation](../../home/architecture.md) first is highly recommended. ## How Authelia integrates with proxies? @@ -22,8 +22,8 @@ Authelia takes authentication requests coming from the proxy and targeting the `/api/verify` endpoint exposed by Authelia. Two pieces of information are required for Authelia to be able to authenticate the user request: -* The session cookie or a `Proxy-Authorization` header (see [single factor authentication](./features/single-factor)). -* The target URL of the user request (used primarily for [access control](./features/access-control)). +* The session cookie or a `Proxy-Authorization` header (see [single factor authentication](../../features/single-factor.md)). +* The target URL of the user request (used primarily for [access control](../../features/access-control.md)). The target URL can be provided using one of the following ways: @@ -45,4 +45,4 @@ login portal if not authenticated yet. If no redirection parameter is provided, the response code is either 200 or 401. The redirection must then be handled by the proxy when an error is detected -(see [nginx](./deployment/supported-proxies/nginx) example). +(see [nginx](./nginx.md) example). diff --git a/docs/security/measures.md b/docs/security/measures.md index baa31182b..a2f65028e 100644 --- a/docs/security/measures.md +++ b/docs/security/measures.md @@ -28,6 +28,23 @@ that the attacker must also require the certificate to retrieve the cookies. Note that using [HSTS] has consequences. That's why you should read the blog post nginx has written on [HSTS]. +## Protections against password cracking (File authentication provider) + +Authelia implements a variety of measures to prevent an attacker cracking passwords if they +somehow obtain the file used by the file authentication provider, this is unrelated to LDAP auth. + +First and foremost Authelia only uses very secure hashing algorithms with sane and secure defaults. +The first and default hashing algorithm we use is Argon2id which is currently considered +the most secure hashing algorithm. We also support SHA512, which previously was the default. + +Secondly Authelia uses salting with all hashing algorithms. These salts are generated with a random +string generator, which is seeded every time it's used by a cryptographically secure 1024bit prime number. +This ensures that even if an attacker obtains the file, each password has to be brute forced individually. + +Lastly Authelia's implementation of Argon2id is highly tunable. You can tune the key length, salt +used, iterations (time), paralellism, and memory usage. To read more about this please read how to +[configure](../configuration/authentication/file.md) file authentication. + ## Notifier security measures (SMTP) By default the SMTP Notifier implementation does not allow connections that are not secure. @@ -100,4 +117,4 @@ add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; ``` -[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ +[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ \ No newline at end of file diff --git a/go.mod b/go.mod index 7e6c60936..01eacc94f 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/onsi/gomega v1.7.1 // indirect github.com/otiai10/copy v1.0.2 github.com/pquerna/otp v1.2.0 - github.com/simia-tech/crypt v0.2.0 + github.com/simia-tech/crypt v0.4.2 github.com/sirupsen/logrus v1.4.2 github.com/spf13/cobra v0.0.5 github.com/spf13/viper v1.6.2 diff --git a/go.sum b/go.sum index d19222238..d6aea9589 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,8 @@ github.com/savsgio/gotils v0.0.0-20190925070755-524bc4f47500 h1:9Pi10H7E8E79/x2H github.com/savsgio/gotils v0.0.0-20190925070755-524bc4f47500/go.mod h1:lHhJedqxCoHN+zMtwGNTXWmF0u9Jt363FYRhV6g0CdY= github.com/simia-tech/crypt v0.2.0 h1:cU8qdqUYNuEFKSMq15yaB2aI1aC5vrn6dFOonT6Kg6o= github.com/simia-tech/crypt v0.2.0/go.mod h1:DMwvjPTzsiHrjqHVW5HvIbF4vUUzMCYDKVLsPWmLdTo= +github.com/simia-tech/crypt v0.4.2 h1:ZQFyCxgImhXpyxWNXEtBfAmV6T8dT1w481fpm8blQww= +github.com/simia-tech/crypt v0.4.2/go.mod h1:DMwvjPTzsiHrjqHVW5HvIbF4vUUzMCYDKVLsPWmLdTo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 3a6b67ec8..1931b4342 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -23,3 +23,22 @@ const ( // PossibleMethods is the set of all possible 2FA methods. var PossibleMethods = []string{TOTP, U2F, Push} + +const ( + //Argon2id Hash Identifier + HashingAlgorithmArgon2id = "argon2id" + //SHA512 Hash Identifier + HashingAlgorithmSHA512 = "6" +) + +// These are the default values from the upstream crypt module, we use them to for GetInt, and they need to be checked when updating github.com/simia-tech/crypt +const ( + HashingDefaultArgon2idTime = 1 + HashingDefaultArgon2idMemory = 32 * 1024 + HashingDefaultArgon2idParallelism = 4 + HashingDefaultArgon2idKeyLength = 32 + HashingDefaultSHA512Iterations = 5000 +) + +// Valid Hashing runes +var HashingPossibleSaltCharacters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/") diff --git a/internal/authentication/file_user_provider.go b/internal/authentication/file_user_provider.go index e8969e900..1c6c47094 100644 --- a/internal/authentication/file_user_provider.go +++ b/internal/authentication/file_user_provider.go @@ -1,21 +1,22 @@ package authentication import ( + "errors" "fmt" "io/ioutil" "strings" "sync" "github.com/asaskevich/govalidator" - + "github.com/authelia/authelia/internal/configuration/schema" "gopkg.in/yaml.v2" ) // FileUserProvider is a provider reading details from a file. type FileUserProvider struct { - path *string - database *DatabaseModel - lock *sync.Mutex + configuration *schema.FileAuthenticationBackendConfiguration + database *DatabaseModel + lock *sync.Mutex } // UserDetailsModel is the model of user details in the file database. @@ -31,8 +32,8 @@ type DatabaseModel struct { } // NewFileUserProvider creates a new instance of FileUserProvider. -func NewFileUserProvider(filepath string) *FileUserProvider { - database, err := readDatabase(filepath) +func NewFileUserProvider(configuration *schema.FileAuthenticationBackendConfiguration) *FileUserProvider { + database, err := readDatabase(configuration.Path) if err != nil { // Panic since the file does not exist when Authelia is starting. panic(err.Error()) @@ -45,9 +46,9 @@ func NewFileUserProvider(filepath string) *FileUserProvider { } return &FileUserProvider{ - path: &filepath, - database: database, - lock: &sync.Mutex{}, + configuration: configuration, + database: database, + lock: &sync.Mutex{}, } } @@ -114,9 +115,24 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) e return fmt.Errorf("User '%s' does not exist in database", username) } - hash := HashPassword(newPassword, "") - details.HashedPassword = fmt.Sprintf("{CRYPT}%s", hash) + var algorithm string + if p.configuration.PasswordHashing.Algorithm == "argon2id" { + algorithm = HashingAlgorithmArgon2id + } else if p.configuration.PasswordHashing.Algorithm == "sha512" { + algorithm = HashingAlgorithmSHA512 + } else { + return errors.New("Invalid algorithm in configuration. It should be `argon2id` or `sha512`") + } + hash, err := HashPassword( + newPassword, "", algorithm, p.configuration.PasswordHashing.Iterations, + p.configuration.PasswordHashing.Memory*1024, p.configuration.PasswordHashing.Parallelism, + p.configuration.PasswordHashing.KeyLength, p.configuration.PasswordHashing.SaltLength) + + if err != nil { + return err + } + details.HashedPassword = hash p.lock.Lock() p.database.Users[username] = details @@ -125,7 +141,7 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) e p.lock.Unlock() return err } - err = ioutil.WriteFile(*p.path, b, 0644) + err = ioutil.WriteFile(p.configuration.Path, b, 0644) p.lock.Unlock() return err } diff --git a/internal/authentication/file_user_provider_test.go b/internal/authentication/file_user_provider_test.go index 2e3d03a25..09fef413f 100644 --- a/internal/authentication/file_user_provider_test.go +++ b/internal/authentication/file_user_provider_test.go @@ -4,8 +4,10 @@ import ( "io/ioutil" "log" "os" + "strings" "testing" + "github.com/authelia/authelia/internal/configuration/schema" "github.com/stretchr/testify/assert" ) @@ -29,9 +31,11 @@ func WithDatabase(content []byte, f func(path string)) { } } -func TestShouldCheckUserPasswordIsCorrect(t *testing.T) { +func TestShouldCheckUserArgon2idPasswordIsCorrect(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) ok, err := provider.CheckUserPassword("john", "password") assert.NoError(t, err) @@ -39,9 +43,23 @@ func TestShouldCheckUserPasswordIsCorrect(t *testing.T) { }) } +func TestShouldCheckUserSHA512PasswordIsCorrect(t *testing.T) { + WithDatabase(UserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("harry", "password") + + assert.NoError(t, err) + assert.True(t, ok) + }) +} + func TestShouldCheckUserPasswordIsWrong(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) ok, err := provider.CheckUserPassword("john", "wrong_password") assert.NoError(t, err) @@ -51,7 +69,9 @@ func TestShouldCheckUserPasswordIsWrong(t *testing.T) { func TestShouldCheckUserPasswordOfUnexistingUser(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) _, err := provider.CheckUserPassword("fake", "password") assert.Error(t, err) assert.Equal(t, "User 'fake' does not exist in database", err.Error()) @@ -60,7 +80,9 @@ func TestShouldCheckUserPasswordOfUnexistingUser(t *testing.T) { func TestShouldRetrieveUserDetails(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) details, err := provider.GetDetails("john") assert.NoError(t, err) assert.Equal(t, details.Emails, []string{"john.doe@authelia.com"}) @@ -70,45 +92,125 @@ func TestShouldRetrieveUserDetails(t *testing.T) { func TestShouldUpdatePassword(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) - err := provider.UpdatePassword("john", "newpassword") + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) + err := provider.UpdatePassword("harry", "newpassword") assert.NoError(t, err) // Reset the provider to force a read from disk. - provider = NewFileUserProvider(path) - ok, err := provider.CheckUserPassword("john", "newpassword") + provider = NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("harry", "newpassword") assert.NoError(t, err) assert.True(t, ok) }) } +// Checks both that the hashing algo changes and that it removes {CRYPT} from the start. +func TestShouldUpdatePasswordHashingAlgorithmToArgon2id(t *testing.T) { + WithDatabase(UserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) + assert.True(t, strings.HasPrefix(provider.database.Users["harry"].HashedPassword, "{CRYPT}$6$")) + err := provider.UpdatePassword("harry", "newpassword") + assert.NoError(t, err) + + // Reset the provider to force a read from disk. + provider = NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("harry", "newpassword") + assert.NoError(t, err) + assert.True(t, ok) + assert.True(t, strings.HasPrefix(provider.database.Users["harry"].HashedPassword, "$argon2id$")) + }) +} + +func TestShouldUpdatePasswordHashingAlgorithmToSHA512(t *testing.T) { + WithDatabase(UserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + config.PasswordHashing.Algorithm = "sha512" + config.PasswordHashing.Iterations = 50000 + + provider := NewFileUserProvider(&config) + assert.True(t, strings.HasPrefix(provider.database.Users["john"].HashedPassword, "{CRYPT}$argon2id$")) + err := provider.UpdatePassword("john", "newpassword") + assert.NoError(t, err) + + // Reset the provider to force a read from disk. + provider = NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("john", "newpassword") + assert.NoError(t, err) + assert.True(t, ok) + assert.True(t, strings.HasPrefix(provider.database.Users["john"].HashedPassword, "$6$")) + }) +} + func TestShouldRaiseWhenLoadingMalformedDatabaseForFirstTime(t *testing.T) { WithDatabase(MalformedUserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path assert.PanicsWithValue(t, "Unable to parse database: yaml: line 4: mapping values are not allowed in this context", func() { - NewFileUserProvider(path) + NewFileUserProvider(&config) }) }) } func TestShouldRaiseWhenLoadingDatabaseWithBadSchemaForFirstTime(t *testing.T) { WithDatabase(BadSchemaUserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path assert.PanicsWithValue(t, "Invalid schema of database: Users: non zero value required", func() { - NewFileUserProvider(path) + NewFileUserProvider(&config) }) }) } -func TestShouldRaiseWhenLoadingDatabaseWithBadHashesForTheFirstTime(t *testing.T) { - WithDatabase(BadHashContent, func(path string) { - assert.PanicsWithValue(t, "Unable to parse hash of user john: Cannot match pattern 'rounds=' to find the number of rounds", func() { - NewFileUserProvider(path) +func TestShouldRaiseWhenLoadingDatabaseWithBadSHA512HashesForTheFirstTime(t *testing.T) { + WithDatabase(BadSHA512HashContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key is not the last parameter, the hash is likely malformed ($6$rounds00000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/).", func() { + NewFileUserProvider(&config) + }) + }) +} + +func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashSettingsForTheFirstTime(t *testing.T) { + WithDatabase(BadArgon2idHashSettingsContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key is not the last parameter, the hash is likely malformed ($argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM).", func() { + NewFileUserProvider(&config) + }) + }) +} + +func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashKeyForTheFirstTime(t *testing.T) { + WithDatabase(BadArgon2idHashKeyContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key contains invalid base64 characters.", func() { + NewFileUserProvider(&config) + }) + }) +} + +func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashSaltForTheFirstTime(t *testing.T) { + WithDatabase(BadArgon2idHashSaltContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Salt contains invalid base64 characters.", func() { + NewFileUserProvider(&config) }) }) } func TestShouldSupportHashPasswordWithoutCRYPT(t *testing.T) { - WithDatabase(UserDatabaseWithouCryptContent, func(path string) { - provider := NewFileUserProvider(path) + WithDatabase(UserDatabaseWithoutCryptContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) ok, err := provider.CheckUserPassword("john", "password") assert.NoError(t, err) @@ -116,10 +218,24 @@ func TestShouldSupportHashPasswordWithoutCRYPT(t *testing.T) { }) } +var ( + DefaultFileAuthenticationBackendConfiguration = schema.FileAuthenticationBackendConfiguration{ + Path: "", + PasswordHashing: &schema.PasswordHashingConfiguration{ + Iterations: schema.DefaultCIPasswordOptionsConfiguration.Iterations, + KeyLength: schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + SaltLength: schema.DefaultCIPasswordOptionsConfiguration.SaltLength, + Algorithm: schema.DefaultCIPasswordOptionsConfiguration.Algorithm, + Memory: schema.DefaultCIPasswordOptionsConfiguration.Memory, + Parallelism: schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + }, + } +) + var UserDatabaseContent = []byte(` users: john: - password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: john.doe@authelia.com groups: - admins @@ -161,7 +277,7 @@ user: - dev `) -var UserDatabaseWithouCryptContent = []byte(` +var UserDatabaseWithoutCryptContent = []byte(` users: john: password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" @@ -174,7 +290,7 @@ users: email: james.dean@authelia.com `) -var BadHashContent = []byte(` +var BadSHA512HashContent = []byte(` users: john: password: "$6$rounds00000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" @@ -186,3 +302,35 @@ users: password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" email: james.dean@authelia.com `) + +var BadArgon2idHashSettingsContent = []byte(` +users: + john: + password: "$argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: john.doe@authelia.com + groups: + - admins + - dev + james: + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: james.dean@authelia.com +`) + +var BadArgon2idHashKeyContent = []byte(` +users: + john: + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$^^vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: john.doe@authelia.com + groups: + - admins + - dev +`) +var BadArgon2idHashSaltContent = []byte(` +users: + john: + password: "$argon2id$v=19$m=65536,t=3,p=2$^^LnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: john.doe@authelia.com + groups: + - admins + - dev +`) diff --git a/internal/authentication/password_hash.go b/internal/authentication/password_hash.go index 4c3fc6824..688f471f0 100644 --- a/internal/authentication/password_hash.go +++ b/internal/authentication/password_hash.go @@ -3,87 +3,149 @@ package authentication import ( "errors" "fmt" - "log" - "math/rand" "strconv" "strings" + "github.com/authelia/authelia/internal/utils" "github.com/simia-tech/crypt" ) // PasswordHash represents all characteristics of a password hash. -// Authelia only supports salted SHA512 method, i.e., $6$ mode. +// Authelia only supports salted SHA512 or salted argon2id method, i.e., $6$ mode or $argon2id$ mode. type PasswordHash struct { - // The number of rounds. - Rounds int - // The salt with a max size of 16 characters for SHA512. - Salt string - // The password hash. - Hash string + Algorithm string + Iterations int + Salt string + Key string + KeyLength int + Memory int + Parallelism int } // ParseHash extracts all characteristics of a hash given its string representation. -func ParseHash(hash string) (*PasswordHash, error) { +func ParseHash(hash string) (passwordHash *PasswordHash, err error) { parts := strings.Split(hash, "$") - if len(parts) != 5 { - return nil, fmt.Errorf("Cannot parse the hash %s", hash) + // This error can be ignored as it's always nil + code, parameters, salt, key, _ := crypt.DecodeSettings(hash) + h := &PasswordHash{} + + h.Salt = salt + h.Key = key + + if h.Key != parts[len(parts)-1] { + return nil, fmt.Errorf("Hash key is not the last parameter, the hash is likely malformed (%s).", hash) + } + if h.Key == "" { + return nil, fmt.Errorf("Hash key contains no characters or the field length is invalid (%s)", hash) } - // Only supports salted sha 512. - if parts[1] != "6" { - return nil, fmt.Errorf("Authelia only supports salted SHA512 hashing ($6$), not $%s$", parts[1]) - } - - roundsKV := strings.Split(parts[2], "=") - if len(roundsKV) != 2 { - return nil, errors.New("Cannot match pattern 'rounds=' to find the number of rounds") - } - - rounds, err := strconv.ParseInt(roundsKV[1], 10, 0) + _, err = crypt.Base64Encoding.DecodeString(h.Salt) if err != nil { - return nil, fmt.Errorf("Cannot find the number of rounds from %s using pattern 'rounds='. Cause: %s", roundsKV[1], err.Error()) + return nil, errors.New("Salt contains invalid base64 characters.") } - return &PasswordHash{ - Rounds: int(rounds), - Salt: parts[3], - Hash: parts[4], - }, nil -} + if code == HashingAlgorithmSHA512 { + h.Iterations = parameters.GetInt("rounds", HashingDefaultSHA512Iterations) + h.Algorithm = HashingAlgorithmSHA512 + if parameters["rounds"] != "" && parameters["rounds"] != strconv.Itoa(h.Iterations) { + return nil, fmt.Errorf("SHA512 iterations is not numeric (%s).", parameters["rounds"]) + } + } else if code == HashingAlgorithmArgon2id { + version := parameters.GetInt("v", 0) + if version < 19 { + if version == 0 { + return nil, fmt.Errorf("Argon2id version parameter not found (%s)", hash) + } + return nil, fmt.Errorf("Argon2id versions less than v19 are not supported (hash is version %d).", version) + } else if version > 19 { + return nil, fmt.Errorf("Argon2id versions greater than v19 are not supported (hash is version %d).", version) + } + h.Algorithm = HashingAlgorithmArgon2id + h.Memory = parameters.GetInt("m", HashingDefaultArgon2idMemory) + h.Iterations = parameters.GetInt("t", HashingDefaultArgon2idTime) + h.Parallelism = parameters.GetInt("p", HashingDefaultArgon2idParallelism) + h.KeyLength = parameters.GetInt("k", HashingDefaultArgon2idKeyLength) -// The set of letters RandomString can pick in. -var possibleLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -// RandomString generate a random string of n characters. -func RandomString(n int) string { - b := make([]rune, n) - for i := range b { - b[i] = possibleLetters[rand.Intn(len(possibleLetters))] + decodedKey, err := crypt.Base64Encoding.DecodeString(h.Key) + if err != nil { + return nil, errors.New("Hash key contains invalid base64 characters.") + } + if len(decodedKey) != h.KeyLength { + return nil, fmt.Errorf("Argon2id key length parameter (%d) does not match the actual key length (%d).", h.KeyLength, len(decodedKey)) + } + } else { + return nil, fmt.Errorf("Authelia only supports salted SHA512 hashing ($6$) and salted argon2id ($argon2id$), not $%s$", code) } - return string(b) + return h, nil } // HashPassword generate a salt and hash the password with the salt and a constant // number of rounds. -func HashPassword(password string, salt string) string { +func HashPassword(password, salt, algorithm string, iterations, memory, parallelism, keyLength, saltLength int) (hash string, err error) { + var settings string + + if algorithm != HashingAlgorithmArgon2id && algorithm != HashingAlgorithmSHA512 { + return "", fmt.Errorf("Hashing algorithm input of '%s' is invalid, only values of %s and %s are supported.", algorithm, HashingAlgorithmArgon2id, HashingAlgorithmSHA512) + } + if salt == "" { - salt = fmt.Sprintf("$6$rounds=50000$%s", RandomString(16)) + if saltLength < 2 { + return "", fmt.Errorf("Salt length input of %d is invalid, it must be 2 or higher.", saltLength) + } else if saltLength > 16 { + return "", fmt.Errorf("Salt length input of %d is invalid, it must be 16 or lower.", saltLength) + } + } else if len(salt) > 16 { + return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 16 or fewer characters.", salt, len(salt)) + } else if len(salt) < 2 { + return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 2 or more characters.", salt, len(salt)) + } else if _, err = crypt.Base64Encoding.DecodeString(salt); err != nil { + return "", fmt.Errorf("Salt input of %s is invalid, only characters [a-zA-Z0-9+/] are valid for input.", salt) } - hash, err := crypt.Crypt(password, salt) - if err != nil { - log.Fatal(err) + + if algorithm == HashingAlgorithmArgon2id { + // Caution: Increasing any of the values in the below block has a high chance in old passwords that cannot be verified. + if memory < 8 { + return "", fmt.Errorf("Memory (argon2id) input of %d is invalid, it must be 8 or higher.", memory) + } + if parallelism < 1 { + return "", fmt.Errorf("Parallelism (argon2id) input of %d is invalid, it must be 1 or higher.", parallelism) + } + if memory < parallelism*8 { + return "", fmt.Errorf("Memory (argon2id) input of %d is invalid with a paraellelism input of %d, it must be %d (parallelism * 8) or higher.", memory, parallelism, parallelism*8) + } + if keyLength < 16 { + return "", fmt.Errorf("Key length (argon2id) input of %d is invalid, it must be 16 or higher.", keyLength) + } + if iterations < 1 { + return "", fmt.Errorf("Iterations (argon2id) input of %d is invalid, it must be 1 or more.", iterations) + } + // Caution: Increasing any of the values in the above block has a high chance in old passwords that cannot be verified. } - return hash + + if salt == "" { + salt = utils.RandomString(saltLength, HashingPossibleSaltCharacters) + } + if algorithm == HashingAlgorithmArgon2id { + settings, _ = crypt.Argon2idSettings(memory, iterations, parallelism, keyLength, salt) + } else if algorithm == HashingAlgorithmSHA512 { + settings = fmt.Sprintf("$6$rounds=%d$%s", iterations, salt) + } + + // This error can be ignored because we check for it before a user gets here + hash, _ = crypt.Crypt(password, settings) + return hash, nil } // CheckPassword check a password against a hash. -func CheckPassword(password string, hash string) (bool, error) { +func CheckPassword(password, hash string) (ok bool, err error) { passwordHash, err := ParseHash(hash) if err != nil { return false, err } - salt := fmt.Sprintf("$6$rounds=%d$%s$", passwordHash.Rounds, passwordHash.Salt) - pHash := HashPassword(password, salt) - return pHash == hash, nil + expectedHash, err := HashPassword(password, passwordHash.Salt, passwordHash.Algorithm, passwordHash.Iterations, passwordHash.Memory, passwordHash.Parallelism, passwordHash.KeyLength, len(passwordHash.Salt)) + if err != nil { + return false, err + } + return hash == expectedHash, nil } diff --git a/internal/authentication/password_hash_test.go b/internal/authentication/password_hash_test.go index 422b9c25a..4b08c8832 100644 --- a/internal/authentication/password_hash_test.go +++ b/internal/authentication/password_hash_test.go @@ -1,55 +1,323 @@ package authentication import ( + "fmt" "testing" + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" + "github.com/simia-tech/crypt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestShouldHashPassword(t *testing.T) { - hash := HashPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S") - assert.Equal(t, "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1", hash) +func TestShouldHashSHA512Password(t *testing.T) { + hash, err := HashPassword("password", "aFr56HjK3DrB8t3S", HashingAlgorithmSHA512, 50000, 0, 0, 0, 16) + + assert.NoError(t, err) + + code, parameters, salt, hash, err := crypt.DecodeSettings(hash) + + assert.Equal(t, "6", code) + assert.Equal(t, "aFr56HjK3DrB8t3S", salt) + assert.Equal(t, "zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1", hash) + assert.Equal(t, schema.DefaultPasswordOptionsSHA512Configuration.Iterations, parameters.GetInt("rounds", HashingDefaultSHA512Iterations)) } -func TestShouldCheckPassword(t *testing.T) { - ok, err := CheckPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") +func TestShouldHashArgon2idPassword(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) assert.NoError(t, err) + + code, parameters, salt, key, err := crypt.DecodeSettings(hash) + + assert.NoError(t, err) + assert.Equal(t, "argon2id", code) + assert.Equal(t, "BpLnfgDsc2WD8F2q", salt) + assert.Equal(t, "O126GHPeZ5fwj7OLSs7PndXsTbje76R+QW9/EGfhkJg", key) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Iterations, parameters.GetInt("t", HashingDefaultArgon2idTime)) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, parameters.GetInt("m", HashingDefaultArgon2idMemory)) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, parameters.GetInt("p", HashingDefaultArgon2idParallelism)) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, parameters.GetInt("k", HashingDefaultArgon2idKeyLength)) +} + +// This checks the method of hashing (for argon2id) supports all the characters we allow in Authelia's hash function +func TestArgon2idHashSaltValidValues(t *testing.T) { + data := string(HashingPossibleSaltCharacters) + datas := utils.SliceString(data, 16) + var hash string + var err error + for _, salt := range datas { + hash, err = HashPassword("password", salt, HashingAlgorithmArgon2id, 1, 8, 1, 32, 16) + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("$argon2id$v=19$m=8,p=1$%s$", salt), hash[0:40]) + } +} + +// This checks the method of hashing (for sha512) supports all the characters we allow in Authelia's hash function +func TestSHA512HashSaltValidValues(t *testing.T) { + data := string(HashingPossibleSaltCharacters) + datas := utils.SliceString(data, 16) + var hash string + var err error + for _, salt := range datas { + hash, err = HashPassword("password", salt, HashingAlgorithmSHA512, 1000, 0, 0, 0, 16) + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("$6$rounds=1000$%s$", salt), hash[0:32]) + } +} + +func TestShouldNotHashPasswordWithNonExistentAlgorithm(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", "bogus", + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Hashing algorithm input of 'bogus' is invalid, only values of argon2id and 6 are supported.") +} + +func TestShouldNotHashArgon2idPasswordDueToMemoryParallelismMismatch(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, 8, 2, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Memory (argon2id) input of 8 is invalid with a paraellelism input of 2, it must be 16 (parallelism * 8) or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueToMemoryLessThanEight(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, 1, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Memory (argon2id) input of 1 is invalid, it must be 8 or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueToKeyLengthLessThanSixteen(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, 5, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Key length (argon2id) input of 5 is invalid, it must be 16 or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueParallelismLessThanOne(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, -1, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Parallelism (argon2id) input of -1 is invalid, it must be 1 or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueIterationsLessThanOne(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + 0, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Iterations (argon2id) input of 0 is invalid, it must be 1 or more.") +} + +func TestShouldNotHashPasswordDueToSaltLength(t *testing.T) { + hash, err := HashPassword("password", "", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, 0) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt length input of 0 is invalid, it must be 2 or higher.") + + hash, err = HashPassword("password", "", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, 20) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt length input of 20 is invalid, it must be 16 or lower.") +} + +func TestShouldNotHashPasswordDueToSaltCharLengthTooLong(t *testing.T) { + hash, err := HashPassword("password", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt input of abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 is invalid (62 characters), it must be 16 or fewer characters.") +} + +func TestShouldNotHashPasswordDueToSaltCharLengthTooShort(t *testing.T) { + hash, err := HashPassword("password", "a", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt input of a is invalid (1 characters), it must be 2 or more characters.") +} + +func TestShouldNotHashPasswordWithNonBase64CharsInSalt(t *testing.T) { + hash, err := HashPassword("password", "abc&123", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt input of abc&123 is invalid, only characters [a-zA-Z0-9+/] are valid for input.") +} + +func TestShouldNotParseHashWithNoneBase64CharsInKey(t *testing.T) { + passwordHash, err := ParseHash("$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$^^vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + assert.EqualError(t, err, "Hash key contains invalid base64 characters.") + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseHashWithNoneBase64CharsInSalt(t *testing.T) { + passwordHash, err := ParseHash("$argon2id$v=19$m=65536$^^wTFoFjITudo57a$Z4NH/EKkdv6PJ01Ye1twJ61fsmRJujZZn1IXdUOyrJY") + assert.EqualError(t, err, "Salt contains invalid base64 characters.") + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseWithMalformedHash(t *testing.T) { + hashExtraField := "$argon2id$v=19$m=65536,t=3,p=2$abc$BpLnfgDsc2WD8F2q$^^vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + hashMissingSaltAndParams := "$argon2id$v=1$2t9X8nNCN2n3/kFYJ3xWNBg5k/rO782Qr7JJoJIK7G4" + hashMissingSalt := "$argon2id$v=1$m=65536,t=3,p=2$2t9X8nNCN2n3/kFYJ3xWNBg5k/rO782Qr7JJoJIK7G4" + + passwordHash, err := ParseHash(hashExtraField) + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hashExtraField)) + assert.Nil(t, passwordHash) + + passwordHash, err = ParseHash(hashMissingSaltAndParams) + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hashMissingSaltAndParams)) + assert.Nil(t, passwordHash) + + passwordHash, err = ParseHash(hashMissingSalt) + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hashMissingSalt)) + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseHashWithEmptyKey(t *testing.T) { + hash := "$argon2id$v=19$m=65536$fvwTFoFjITudo57a$" + passwordHash, err := ParseHash(hash) + assert.EqualError(t, err, fmt.Sprintf("Hash key contains no characters or the field length is invalid (%s)", hash)) + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseArgon2idHashWithEmptyVersion(t *testing.T) { + hash := "$argon2id$m=65536$fvwTFoFjITudo57a$Z4NH/EKkdv6PJ01Ye1twJ61fsmRJujZZn1IXdUOyrJY" + passwordHash, err := ParseHash(hash) + assert.EqualError(t, err, fmt.Sprintf("Argon2id version parameter not found (%s)", hash)) + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseArgon2idHashWithWrongKeyLength(t *testing.T) { + hash := "$argon2id$v=19$m=65536,k=50$fvwTFoFjITudo57a$Z4NH/EKkdv6PJ01Ye1twJ61fsmRJujZZn1IXdUOyrJY" + passwordHash, err := ParseHash(hash) + assert.EqualError(t, err, "Argon2id key length parameter (50) does not match the actual key length (32).") + assert.Nil(t, passwordHash) +} + +func TestShouldParseArgon2idHash(t *testing.T) { + passwordHash, err := ParseHash("$argon2id$v=19$m=131072,t=1,p=8$BpLnfgDsc2WD8F2q$G4fD5nJwXHDMS+u0eEMKvU0LF23jxbSmJSxhSLTteHE") + assert.NoError(t, err) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Iterations, passwordHash.Iterations) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, passwordHash.Parallelism) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, passwordHash.KeyLength) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, passwordHash.Memory) +} + +func TestShouldCheckSHA512Password(t *testing.T) { + ok, err := CheckPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") + assert.NoError(t, err) assert.True(t, ok) } -func TestCannotParseHash(t *testing.T) { +func TestShouldCheckArgon2idPassword(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + assert.NoError(t, err) + assert.True(t, ok) +} + +func TestCannotParseSHA512Hash(t *testing.T) { ok, err := CheckPassword("password", "$6$roSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") - assert.EqualError(t, err, "Cannot parse the hash $6$roSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") + assert.EqualError(t, err, "Hash key is not the last parameter, the hash is likely malformed ($6$roSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1).") assert.False(t, ok) } -func TestOnlySupportSHA512(t *testing.T) { +func TestCannotParseArgon2idHash(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Hash key is not the last parameter, the hash is likely malformed ($argon2id$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM).") + assert.False(t, ok) +} + +func TestOnlySupportSHA512AndArgon2id(t *testing.T) { ok, err := CheckPassword("password", "$8$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") - assert.EqualError(t, err, "Authelia only supports salted SHA512 hashing ($6$), not $8$") + assert.EqualError(t, err, "Authelia only supports salted SHA512 hashing ($6$) and salted argon2id ($argon2id$), not $8$") assert.False(t, ok) } func TestCannotFindNumberOfRounds(t *testing.T) { - ok, err := CheckPassword("password", "$6$rounds50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") + hash := "$6$rounds50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1" + ok, err := CheckPassword("password", hash) - assert.EqualError(t, err, "Cannot match pattern 'rounds=' to find the number of rounds") + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hash)) + assert.False(t, ok) +} + +func TestCannotMatchArgon2idParamPattern(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Hash key is not the last parameter, the hash is likely malformed ($argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM).") + assert.False(t, ok) +} + +func TestArgon2idVersionLessThanSupported(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=18$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Argon2id versions less than v19 are not supported (hash is version 18).") + assert.False(t, ok) +} + +func TestArgon2idVersionGreaterThanSupported(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=20$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Argon2id versions greater than v19 are not supported (hash is version 20).") assert.False(t, ok) } func TestNumberOfRoundsNotInt(t *testing.T) { ok, err := CheckPassword("password", "$6$rounds=abc$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") - assert.EqualError(t, err, "Cannot find the number of rounds from abc using pattern 'rounds='. Cause: strconv.ParseInt: parsing \"abc\": invalid syntax") + assert.EqualError(t, err, "SHA512 iterations is not numeric (abc).") assert.False(t, ok) } -func TestShouldCheckPasswordHashedWithAuthelia(t *testing.T) { +func TestShouldCheckPasswordArgon2idHashedWithAuthelia(t *testing.T) { password := "my;secure*password" - hash := HashPassword(password, "") + hash, err := HashPassword(password, "", HashingAlgorithmArgon2id, schema.DefaultCIPasswordOptionsConfiguration.Iterations, + schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.NoError(t, err) + + equal, err := CheckPassword(password, hash) + + require.NoError(t, err) + assert.True(t, equal) +} + +func TestShouldCheckPasswordSHA512HashedWithAuthelia(t *testing.T) { + password := "my;secure*password" + hash, err := HashPassword(password, "", HashingAlgorithmSHA512, schema.DefaultPasswordOptionsSHA512Configuration.Iterations, + 0, 0, 0, schema.DefaultPasswordOptionsSHA512Configuration.SaltLength) + + assert.NoError(t, err) + equal, err := CheckPassword(password, hash) require.NoError(t, err) diff --git a/internal/commands/hash.go b/internal/commands/hash.go index 32be714dd..0c27cbb67 100644 --- a/internal/commands/hash.go +++ b/internal/commands/hash.go @@ -4,14 +4,51 @@ import ( "fmt" "github.com/authelia/authelia/internal/authentication" + "github.com/authelia/authelia/internal/configuration/schema" "github.com/spf13/cobra" ) +func init() { + HashPasswordCmd.Flags().BoolP("sha512", "z", false, fmt.Sprintf("use sha512 as the algorithm (changes iterations to %d, change with -i)", schema.DefaultPasswordOptionsSHA512Configuration.Iterations)) + HashPasswordCmd.Flags().IntP("iterations", "i", schema.DefaultPasswordOptionsConfiguration.Iterations, "set the number of hashing iterations") + HashPasswordCmd.Flags().StringP("salt", "s", "", "set the salt string") + HashPasswordCmd.Flags().IntP("memory", "m", schema.DefaultPasswordOptionsConfiguration.Memory, "[argon2id] set the amount of memory param (in MB)") + HashPasswordCmd.Flags().IntP("parallelism", "p", schema.DefaultPasswordOptionsConfiguration.Parallelism, "[argon2id] set the parallelism param") + HashPasswordCmd.Flags().IntP("key-length", "k", schema.DefaultPasswordOptionsConfiguration.KeyLength, "[argon2id] set the key length param") + HashPasswordCmd.Flags().IntP("salt-length", "l", schema.DefaultPasswordOptionsConfiguration.SaltLength, "set the auto-generated salt length") +} + var HashPasswordCmd = &cobra.Command{ Use: "hash-password [password]", - Short: "Hash a password to be used in file-based users database", + Short: "Hash a password to be used in file-based users database. Default algorithm is argon2id.", Run: func(cobraCmd *cobra.Command, args []string) { - fmt.Println(authentication.HashPassword(args[0], "")) + sha512, _ := cobraCmd.Flags().GetBool("sha512") + iterations, _ := cobraCmd.Flags().GetInt("iterations") + salt, _ := cobraCmd.Flags().GetString("salt") + keyLength, _ := cobraCmd.Flags().GetInt("key-length") + saltLength, _ := cobraCmd.Flags().GetInt("salt-length") + memory, _ := cobraCmd.Flags().GetInt("memory") + parallelism, _ := cobraCmd.Flags().GetInt("parallelism") + + var err error + var hash string + var algorithm string + + if sha512 { + if iterations == schema.DefaultPasswordOptionsConfiguration.Iterations { + iterations = schema.DefaultPasswordOptionsSHA512Configuration.Iterations + } + algorithm = authentication.HashingAlgorithmSHA512 + } else { + algorithm = authentication.HashingAlgorithmArgon2id + } + + hash, err = authentication.HashPassword(args[0], salt, algorithm, iterations, memory*1024, parallelism, keyLength, saltLength) + if err != nil { + fmt.Println(fmt.Sprintf("Error occured during hashing: %s", err)) + } else { + fmt.Println(fmt.Sprintf("Password hash: %s", hash)) + } }, Args: cobra.MinimumNArgs(1), } diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index b7f2f1d08..6b6a85e2a 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -17,7 +17,44 @@ type LDAPAuthenticationBackendConfiguration struct { // FileAuthenticationBackendConfiguration represents the configuration related to file-based backend type FileAuthenticationBackendConfiguration struct { - Path string `mapstructure:"path"` + Path string `mapstructure:"path"` + PasswordHashing *PasswordHashingConfiguration `mapstructure:"password"` +} + +type PasswordHashingConfiguration struct { + Iterations int `mapstructure:"iterations"` + KeyLength int `mapstructure:"key_length"` + SaltLength int `mapstructure:"salt_length"` + Algorithm string `mapstrucutre:"algorithm"` + Memory int `mapstructure:"memory"` + Parallelism int `mapstructure:"parallelism"` +} + +// Default Argon2id Configuration +var DefaultPasswordOptionsConfiguration = PasswordHashingConfiguration{ + Iterations: 1, + KeyLength: 32, + SaltLength: 16, + Algorithm: "argon2id", + Memory: 1024, + Parallelism: 8, +} + +// Default Argon2id Configuration for CI testing when calling HashPassword() +var DefaultCIPasswordOptionsConfiguration = PasswordHashingConfiguration{ + Iterations: 1, + KeyLength: 32, + SaltLength: 16, + Algorithm: "argon2id", + Memory: 128, + Parallelism: 8, +} + +// Default SHA512 Cofniguration +var DefaultPasswordOptionsSHA512Configuration = PasswordHashingConfiguration{ + Iterations: 50000, + SaltLength: 16, + Algorithm: "sha512", } // AuthenticationBackendConfiguration represents the configuration related to the authentication backend. diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index f9968e911..7bc06e9c1 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -16,6 +16,63 @@ func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationB if configuration.Path == "" { validator.Push(errors.New("Please provide a `path` for the users database in `authentication_backend`")) } + + if configuration.PasswordHashing == nil { + configuration.PasswordHashing = &schema.DefaultPasswordOptionsConfiguration + } else { + if configuration.PasswordHashing.Algorithm == "" { + configuration.PasswordHashing.Algorithm = schema.DefaultPasswordOptionsConfiguration.Algorithm + } else { + configuration.PasswordHashing.Algorithm = strings.ToLower(configuration.PasswordHashing.Algorithm) + if configuration.PasswordHashing.Algorithm != "argon2id" && configuration.PasswordHashing.Algorithm != "sha512" { + validator.Push(fmt.Errorf("Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured '%s'", configuration.PasswordHashing.Algorithm)) + } + } + + // Iterations (time) + if configuration.PasswordHashing.Iterations == 0 { + if configuration.PasswordHashing.Algorithm == "argon2id" { + configuration.PasswordHashing.Iterations = schema.DefaultPasswordOptionsConfiguration.Iterations + } else { + configuration.PasswordHashing.Iterations = schema.DefaultPasswordOptionsSHA512Configuration.Iterations + } + } else if configuration.PasswordHashing.Iterations < 1 { + validator.Push(fmt.Errorf("The number of iterations specified is invalid, must be 1 or more, you configured %d", configuration.PasswordHashing.Iterations)) + } + + //Salt Length + if configuration.PasswordHashing.SaltLength == 0 { + configuration.PasswordHashing.SaltLength = schema.DefaultPasswordOptionsConfiguration.SaltLength + } else if configuration.PasswordHashing.SaltLength < 2 { + validator.Push(fmt.Errorf("The salt length must be 2 or more, you configured %d", configuration.PasswordHashing.SaltLength)) + } else if configuration.PasswordHashing.SaltLength > 16 { + validator.Push(fmt.Errorf("The salt length must be 16 or less, you configured %d", configuration.PasswordHashing.SaltLength)) + } + + if configuration.PasswordHashing.Algorithm == "argon2id" { + + // Parallelism + if configuration.PasswordHashing.Parallelism == 0 { + configuration.PasswordHashing.Parallelism = schema.DefaultPasswordOptionsConfiguration.Parallelism + } else if configuration.PasswordHashing.Parallelism < 1 { + validator.Push(fmt.Errorf("Parallelism for argon2id must be 1 or more, you configured %d", configuration.PasswordHashing.Parallelism)) + } + + // Memory + if configuration.PasswordHashing.Memory == 0 { + configuration.PasswordHashing.Memory = schema.DefaultPasswordOptionsConfiguration.Memory + } else if configuration.PasswordHashing.Memory < configuration.PasswordHashing.Parallelism*8 { + validator.Push(fmt.Errorf("Memory for argon2id must be %d or more (parallelism * 8), you configured memory as %d and parallelism as %d", configuration.PasswordHashing.Parallelism*8, configuration.PasswordHashing.Memory, configuration.PasswordHashing.Parallelism)) + } + + // Key Length + if configuration.PasswordHashing.KeyLength == 0 { + configuration.PasswordHashing.KeyLength = schema.DefaultPasswordOptionsConfiguration.KeyLength + } else if configuration.PasswordHashing.KeyLength < 16 { + validator.Push(fmt.Errorf("Key length for argon2id must be 16, you configured %d", configuration.PasswordHashing.KeyLength)) + } + } + } } func validateLdapURL(ldapURL string, validator *schema.StructValidator) string { diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index f61dbebdc..a3333f172 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -28,9 +28,16 @@ type FileBasedAuthenticationBackend struct { func (suite *FileBasedAuthenticationBackend) SetupTest() { suite.validator = schema.NewStructValidator() suite.configuration = schema.AuthenticationBackendConfiguration{} - suite.configuration.File = &schema.FileAuthenticationBackendConfiguration{Path: "/a/path"} + suite.configuration.File = &schema.FileAuthenticationBackendConfiguration{Path: "/a/path", PasswordHashing: &schema.PasswordHashingConfiguration{ + Algorithm: schema.DefaultPasswordOptionsConfiguration.Algorithm, + Iterations: schema.DefaultPasswordOptionsConfiguration.Iterations, + Parallelism: schema.DefaultPasswordOptionsConfiguration.Parallelism, + Memory: schema.DefaultPasswordOptionsConfiguration.Memory, + KeyLength: schema.DefaultPasswordOptionsConfiguration.KeyLength, + SaltLength: schema.DefaultPasswordOptionsConfiguration.SaltLength, + }} + suite.configuration.File.PasswordHashing.Algorithm = schema.DefaultPasswordOptionsConfiguration.Algorithm } - func (suite *FileBasedAuthenticationBackend) TestShouldValidateCompleteConfiguration() { ValidateAuthenticationBackend(&suite.configuration, suite.validator) assert.Len(suite.T(), suite.validator.Errors(), 0) @@ -43,6 +50,107 @@ func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenNoPathProvi assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a `path` for the users database in `authentication_backend`") } +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenMemoryNotMoreThanEightTimesParallelism() { + suite.configuration.File.PasswordHashing.Memory = 8 + suite.configuration.File.PasswordHashing.Parallelism = 2 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Memory for argon2id must be 16 or more (parallelism * 8), you configured memory as 8 and parallelism as 2") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWhenBlank() { + suite.configuration.File.PasswordHashing = &schema.PasswordHashingConfiguration{} + + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.KeyLength) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), "", suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.Parallelism) + + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.KeyLength, suite.configuration.File.PasswordHashing.KeyLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Iterations, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.SaltLength, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Algorithm, suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Memory, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Parallelism, suite.configuration.File.PasswordHashing.Parallelism) +} + +func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWhenOnlySHA512Set() { + suite.configuration.File.PasswordHashing = &schema.PasswordHashingConfiguration{} + assert.Equal(suite.T(), "", suite.configuration.File.PasswordHashing.Algorithm) + suite.configuration.File.PasswordHashing.Algorithm = "sha512" + + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.KeyLength, suite.configuration.File.PasswordHashing.KeyLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Iterations, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.SaltLength, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Algorithm, suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Memory, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Parallelism, suite.configuration.File.PasswordHashing.Parallelism) +} +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenKeyLengthTooLow() { + suite.configuration.File.PasswordHashing.KeyLength = 1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Key length for argon2id must be 16, you configured 1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenSaltLengthTooLow() { + suite.configuration.File.PasswordHashing.SaltLength = -1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "The salt length must be 2 or more, you configured -1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenSaltLengthTooHigh() { + suite.configuration.File.PasswordHashing.SaltLength = 20 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "The salt length must be 16 or less, you configured 20") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenBadAlgorithmDefined() { + suite.configuration.File.PasswordHashing.Algorithm = "bogus" + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured 'bogus'") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenIterationsTooLow() { + suite.configuration.File.PasswordHashing.Iterations = -1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "The number of iterations specified is invalid, must be 1 or more, you configured -1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenParallelismTooLow() { + suite.configuration.File.PasswordHashing.Parallelism = -1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Parallelism for argon2id must be 1 or more, you configured -1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultValues() { + suite.configuration.File.PasswordHashing.Algorithm = "" + suite.configuration.File.PasswordHashing.Iterations = 0 + suite.configuration.File.PasswordHashing.SaltLength = 0 + suite.configuration.File.PasswordHashing.Memory = 0 + suite.configuration.File.PasswordHashing.Parallelism = 0 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Algorithm, suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Iterations, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.SaltLength, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Memory, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Parallelism, suite.configuration.File.PasswordHashing.Parallelism) +} + func TestFileBasedAuthenticationBackend(t *testing.T) { suite.Run(t, new(FileBasedAuthenticationBackend)) } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index 805d02881..32cdd8654 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,6 +1,12 @@ package utils -func IsStringInSlice(a string, list []string) bool { +import ( + "math/rand" + "time" +) + +// Checks if a single string is in an array of strings +func IsStringInSlice(a string, list []string) (inSlice bool) { for _, b := range list { if b == a { return true @@ -8,3 +14,28 @@ func IsStringInSlice(a string, list []string) bool { } return false } + +// Splits a string s into an array with each item being a max of int d +// d = denominator, n = numerator, q = quotient, r = remainder +func SliceString(s string, d int) (array []string) { + n := len(s) + q := n / d + r := n % d + for i := 0; i < q; i++ { + array = append(array, s[i*d:i*d+d]) + if i+1 == q && r != 0 { + array = append(array, s[i*d+d:]) + } + } + return +} + +// RandomString generate a random string of n characters +func RandomString(n int, characters []rune) (randomString string) { + rand.Seed(time.Now().UnixNano()) + b := make([]rune, n) + for i := range b { + b[i] = characters[rand.Intn(len(characters))] + } + return string(b) +} diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go new file mode 100644 index 000000000..71523836c --- /dev/null +++ b/internal/utils/strings_test.go @@ -0,0 +1,37 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) { + input := "abcdefghijkl" + arrayOfStrings := SliceString(input, 4) + assert.Equal(t, len(arrayOfStrings), 3) + assert.Equal(t, "abcd", arrayOfStrings[0]) + assert.Equal(t, "efgh", arrayOfStrings[1]) + assert.Equal(t, "ijkl", arrayOfStrings[2]) +} + +func TestShouldSplitIntoEvenStringsOfOne(t *testing.T) { + input := "abcdefghijkl" + arrayOfStrings := SliceString(input, 1) + assert.Equal(t, 12, len(arrayOfStrings)) + assert.Equal(t, "a", arrayOfStrings[0]) + assert.Equal(t, "b", arrayOfStrings[1]) + assert.Equal(t, "c", arrayOfStrings[2]) + assert.Equal(t, "d", arrayOfStrings[3]) + assert.Equal(t, "l", arrayOfStrings[11]) +} + +func TestShouldSplitIntoUnevenStringsOfFour(t *testing.T) { + input := "abcdefghijklm" + arrayOfStrings := SliceString(input, 4) + assert.Equal(t, len(arrayOfStrings), 4) + assert.Equal(t, "abcd", arrayOfStrings[0]) + assert.Equal(t, "efgh", arrayOfStrings[1]) + assert.Equal(t, "ijkl", arrayOfStrings[2]) + assert.Equal(t, "m", arrayOfStrings[3]) +}