From aa487a9a05e3e4a0e4885d912c010d9d1baa99a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20G=C3=B3mez?= Date: Sun, 28 Jun 2020 04:15:17 -0400 Subject: [PATCH] Refactor hashing: add support for bcrypt and argond2id hashers. Fix cache security issue. --- .github/CODEOWNERS | 8 ++ Makefile | 2 +- README.md | 151 ++++++++++++++++++++++++++++--- backends/db.go | 31 +++++++ backends/files.go | 27 ++---- backends/files_test.go | 5 +- backends/grpc_test.go | 6 +- backends/http_test.go | 1 - backends/jwt.go | 12 ++- backends/jwt_test.go | 20 ++-- backends/mongo.go | 23 ++--- backends/mongo_test.go | 10 +- backends/mysql.go | 24 ++--- backends/mysql_test.go | 5 +- backends/postgres.go | 24 ++--- backends/postgres_test.go | 5 +- backends/redis.go | 22 ++--- backends/redis_test.go | 3 +- backends/sqlite.go | 24 ++--- backends/sqlite_test.go | 10 +- backends/topic.go | 22 +++++ cache/cache.go | 35 ++++--- common/utils.go | 186 -------------------------------------- go-auth.go | 20 ++-- hashing/argon2id.go | 107 ++++++++++++++++++++++ hashing/bcrypt.go | 30 ++++++ hashing/hashing.go | 150 ++++++++++++++++++++++++++++++ hashing/hashing_test.go | 147 ++++++++++++++++++++++++++++++ hashing/pbkdf2.go | 145 +++++++++++++++++++++++++++++ pw-gen/pw.go | 43 ++++++--- 30 files changed, 925 insertions(+), 373 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 backends/db.go create mode 100644 backends/topic.go delete mode 100644 common/utils.go create mode 100644 hashing/argon2id.go create mode 100644 hashing/bcrypt.go create mode 100644 hashing/hashing.go create mode 100644 hashing/hashing_test.go create mode 100644 hashing/pbkdf2.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2125e91 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# This is a comment. +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @global-owner1 and @global-owner2 will be requested for +# review when someone opens a pull request. +* @iegomez \ No newline at end of file diff --git a/Makefile b/Makefile index 7be5c6a..ae4ed06 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ dev-requirements: go get -u github.com/smartystreets/goconvey test: - go test ./backends ./cache -v -bench=none -count=1 + go test ./backends ./cache ./hashing -v -bench=none -count=1 benchmark: go test ./backends -v -bench=. -run=^a diff --git a/README.md b/README.md index 115edf6..1b828c3 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ Mosquitto Go Auth is an authentication and authorization plugin for the Mosquitt ### Intro -This is an authentication and authorization plugin for [mosquitto](https://mosquitto.org/), a well known open source MQTT broker. It's written (almost) entirely in Go: it uses cgo to expose mosquitto's auth plugin needed functions, but internally just calls Go to get everything done. +This is an authentication and authorization plugin for [mosquitto](https://mosquitto.org/), a well known open source MQTT broker. It's written (almost) entirely in Go: it uses `cgo` to expose mosquitto's auth plugin needed functions, but internally just calls Go to get everything done. It is greatly inspired in [jpmens'](https://github.com/jpmens) [mosquitto-auth-plug](https://github.com/jpmens/mosquitto-auth-plug). -It was intended for use with [brocaar's](https://github.com/brocaar) [Loraserver project](https://www.loraserver.io/), and thus Files, Postgres and JWT backends were the first to be developed, but more have been added. These are the backends that this plugin implements right now: +These are the backends that this plugin implements right now: * Files * PostgreSQL @@ -35,6 +35,7 @@ Please open an issue with the `feature` or `enhancement` tag to request new back - [Configuration](#configuration) - [General options](#general-options) - [Cache](#cache) + - [Hashing](#hashing) - [Log level](#log-level) - [Prefixes](#prefixes) - [Backend options](#backend-options) @@ -76,23 +77,23 @@ Please open an issue with the `feature` or `enhancement` tag to request new back ### Requirements This package uses `Go modules` to manage dependencies, `dep` is no longer supported. -As it interacts with mosquitto, it makes use of Cgo. Also, it (optionally) uses Redis for cache purposes. +As it interacts with `mosquitto`, it makes use of `cgo`. Also, it (optionally) uses Redis for cache purposes. ### Build -Before building, you need to build mosquitto. For completeness, we'll build it with websockets, ssl and srv support. +Before building, you need to build `mosquitto`. For completeness, we'll build it with `websockets`, `tls` and `srv` support. -First, install dependencies (tested on Debian 9 and later, Linux Mint 18 and 19): +First, install dependencies (tested on Debian 9 and later, Linux Mint 18, 19 and 20): -`sudo apt-get install libwebsockets8 libwebsockets-dev libc-ares2 libc-ares-dev openssl uuid uuid-dev` +`sudo apt-get install libwebsockets8 libwebsockets-dev libc-ares2 libc-ares-dev openssl uuid uuid-dev` Download mosquitto and extract it (**change versions accordingly**): ``` wget http://mosquitto.org/files/source/mosquitto-1.6.8.tar.gz -tar xzvf mosquitto-1.6.8.tar.gz -cd mosquitto-1.6.8 +tar xzvf mosquitto-1.6.10.tar.gz +cd mosquitto-1.6.10 ``` Modify config.mk, setting websockets support. Then build mosquitto, add a mosquitto user and set ownership for /var/log/mosquitto and /var/lib/mosquitto/ (default log and persistence locations). @@ -278,6 +279,61 @@ auth_opt_cache_addresses host1:port1,host2:port2,host3:port3 Notice that if `cache_mode` is not provided or isn't equal to `cluster`, cache will default to use a single instance with the common options. If instead the mode is set to `cluster` but no addresses are given, the plugin will default to not use a cache. +#### Hashing + +There are 3 options for password hashing available: `PBKDF2` (default), `Bcrypt` and `Argon2ID`. Every backend that needs one -that's all but `grpc`, `http` and `custom`- gets a hasher and whether it uses specific options or general ones depends on the auth opts passed. + +Provided options define what hasher each backend will use: +- If there are general hashing options available but no backend ones, then every backend will use those general ones for its hasher. +- If there are no options available in general and none for a given backend either, that backend will use defaults (see `hashing/hashing.go` for default values). +- If there are options for a given backend but no general ones, the backend will use its own hasher and any backend that doesn't register a hasher will use defaults. + +You may set the desired general hasher with this option, passing either `pbkdf2`, `bcrypt` or `argon2id` values. When not set, the option will default to `pbkdf2`. + +``` +auth_opt_hasher pbkdf2 + +``` + +Each hasher has specific options. Notice that when using the `pw` utility, these values must match those used to generate the password. + +##### PBKDF2 + +``` +auth_opt_salt_size 16 # salt bytes length +auth_opt_iterations 100000 # number of iterations +auth_opt_keylen 64 # key length +auth_opt_algorithm sha512 # hashing algorithm, either sha512 (default) or sha256 +auth_opt_salt_encoding # salt encoding, either base64 (default) or utf-8 +``` + +##### Bcrypt + +``` +auth_opt_cost 10 # key expansion iteration count +``` + +##### Argon2ID + +``` +auth_opt_salt_size 16 # salt bytes length +auth_opt_iterations 3 # number of iterations +auth_opt_keylen 64 # key length +auth_opt_memory 4096 # amount of memory (in kibibytes) to use +auth_opt_parallelism 2 # degree of parallelism (i.e. number of threads) +``` + +**These options may be defined for each backend that needs a hasher by prepending the backend's name to the option, e.g. for setting `argon2id` as `Postgres'` hasher**: + +``` +auth_opt_pg_hasher argon2id +auth_opt_pg_salt_size 16 # salt bytes length +auth_opt_pg_iterations 3 # number of iterations +auth_opt_pg_keylen 64 # key length +auth_opt_pg_memory 4096 # amount of memory (in kibibytes) to use +auth_opt_pg_parallelism # degree of parallelism (i.e. number of threads) +``` + #### Logging You can set the log level with the `log_level` option. Valid values are: debug, info, warn, error, fatal and panic. If not set, default value is `info`. @@ -297,7 +353,7 @@ If `log_dest` or `log_file` are invalid, or if there's an error opening the file #### Prefixes -Though the plugin may have multiple backends enabled, there's a way to specify which backend must be used for a given user: prefixes. When enabled, `prefixes` allows to check if the username contains a predefined prefix in the form prefix_username and use the configured backend for that prefix. Options to enable and set prefixes are the following: +Though the plugin may have multiple backends enabled, there's a way to specify which backend must be used for a given user: prefixes. When enabled, `prefixes` allow to check if the username contains a predefined prefix in the form prefix_username and use the configured backend for that prefix. Options to enable and set prefixes are the following: ``` auth_opt_check_prefix true @@ -319,6 +375,38 @@ auth_opt_disable_superuser true Any other value or missing option will have `superuser` enabled. +#### ACL access values + +Mosquitto 1.5 introduced a new ACL access value, `MOSQ_ACL_SUBSCRIBE`, which is similar to the classic `MOSQ_ACL_READ` value but not quite the same: + +``` + * MOSQ_ACL_SUBSCRIBE when a client is asking to subscribe to a topic string. + * This differs from MOSQ_ACL_READ in that it allows you to + * deny access to topic strings rather than by pattern. For + * example, you may use MOSQ_ACL_SUBSCRIBE to deny + * subscriptions to '#', but allow all topics in + * MOSQ_ACL_READ. This allows clients to subscribe to any + * topic they want, but not discover what topics are in use + * on the server. + * MOSQ_ACL_READ when a message is about to be sent to a client (i.e. whether + * it can read that topic or not). +``` + +The main difference is that subscribe is checked at first, when a client connects and tells the broker it wants to subscribe to some topic, while read is checked when an actual message is being published to that topic, which makes it particular. +So in practice you could deny general subscriptions such as # by returning false from the acl check when you receive `MOSQ_ACL_SUBSCRIBE`, but allow any particular one by returning true on `MOSQ_ACL_READ`. +Please take this into consideration when designing your ACL records on every backend. + +Also, these are the current available values from `mosquitto`: + +``` +#define MOSQ_ACL_NONE 0x00 +#define MOSQ_ACL_READ 0x01 +#define MOSQ_ACL_WRITE 0x02 +#define MOSQ_ACL_SUBSCRIBE 0x04 +``` + +If you're using prior versions then `MOSQ_ACL_SUBSCRIBE` is not available and you don't need to worry about it. + #### Backend options Any other options with a leading ```auth_opt_``` are handed to the plugin and used by the backends. @@ -333,7 +421,7 @@ This issue captures these concerns and a basic plan to refactor tests: https://g ### Files -The `files` backend implements the regular password and acl checks as described in mosquitto. Passwords should be in PBKDF2 format (for other backends too), and may be generated using the `pw` utility (built by default when running `make`) included in the plugin (or one of your own). Passwords may also be tested using the [pw-test package](https://github.com/iegomez/pw-test). +The `files` backend implements the regular password and acl checks as described in mosquitto. Passwords should be in `PBKDF2`, `Bcrypt` or `Argon2ID` format (for other backends too), see [Hashing](#hashing) for more details about different hashing strategies. Hashes may be generated using the `pw` utility (built by default when running `make`) included in the plugin (or one of your own). Passwords may also be tested using the [pw-test package](https://github.com/iegomez/pw-test). Usage of `pw`: @@ -341,15 +429,28 @@ Usage of `pw`: Usage of ./pw: -a string algorithm: sha256 or sha512 (default "sha512") + -c int + bcrypt ost param (default 10) + -e string + salt encoding (default "base64") + -h string + hasher: pbkdf2, argon2 or bcrypt (default "pbkdf2") -i int - hash iterations (default 100000) + hash iterations: defaults to 100000 for pbkdf2, please set to a reasonable value for argon2 (default 100000) + -l int + key length, recommended values are 32 for sha256 and 64 for sha512 + -m int + memory for argon2 hash (default 4096) -p string password + -pl int + parallelism for argon2 (default 2) -s int salt size (default 16) + ``` -For this backend passwords and acls file paths must be given: +For this backend `passwords` and `acls` file paths must be given: ``` auth_opt_password_path /path/to/password_file @@ -455,7 +556,7 @@ Queries work pretty much the same as in jpmen's plugin, so here's his discriptio In the following example, the table has a column `rw` containing 1 for readonly topics, 2 for writeonly topics and 3 for readwrite topics: - SELECT topic FROM acl WHERE (username = $1) AND (rw = $2 or rw = 3) + SELECT topic FROM acl WHERE (username = $1) AND rw = $2 When option pg_superquery is not present, Superuser check will always return false, hence there'll be no superusers. @@ -476,6 +577,9 @@ auth_opt_pg_aclquery select distinct 'application/' || a.id || '/#' from "user" ``` +#### Password hashing + +For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing). #### Testing Postgres @@ -527,7 +631,7 @@ To allow native passwords, set the option to true: auth_opt_mysql_allow_native_passwords true ``` -Finally, placeholders for mysql differ from those of postgres, changing from $1, $2, etc., to simply ?. So, following the postgres examples, same queries for mysql would look like these: +Finally, placeholders for mysql differ from those of postgres, changing from $1, $2, etc., to simply ?. These are some **example** queries for `mysql`: User query: @@ -545,9 +649,12 @@ SELECT COUNT(*) FROM account WHERE username = ? AND super = 1 Acl query: ```sql -SELECT topic FROM acl WHERE (username = ?) AND rw >= ? +SELECT topic FROM acl WHERE (username = ?) AND rw = ? ``` +#### Password hashing + +For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing). #### Testing Mysql @@ -616,6 +723,9 @@ sqlite_superquery SELECT COUNT(*) FROM account WHERE username = ? AND super = 1 sqlite_aclquery SELECT topic FROM acl WHERE (username = ?) AND rw >= ? ``` +#### Password hashing + +For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing). #### Testing SQLite3 @@ -813,6 +923,9 @@ When option jwt_superquery is not present, Superuser check will always return fa When option jwt_aclquery is not present, AclCheck will always return true, hence all authenticated users will be authorized to pub/sub to any topic. +#### Password hashing + +When using local mode, a hasher is expected. For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing). #### Prefixes @@ -911,6 +1024,10 @@ When not present, host defaults to "localhost", port to 6379, db to 2 and no pas If you want to use a Redis Cluster as your backend, you need to set `auth_opt_redis_mode` to `cluster` and provide the different addresses as a list of comma separated `host:port` strings with the `auth_opt_redis_addresses` options. If `auth_opt_redis_mode` is set to another value or not set, Redis defaults to single instance behaviour. If it is correctly set but no addresses are given, the backend will fail to initialize. +#### Password hashing + +For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing). + #### Testing Redis In order to test the Redis backend, the plugin needs to be able to connect to a redis server located at localhost, on port 6379, without using password and that a database named 2 exists (to avoid messing with the commonly used 0 and 1). @@ -990,6 +1107,10 @@ When not set, these options default to: If you experience any problem connecting to a replica set, please refer to [this issue](https://github.com/iegomez/mosquitto-go-auth/issues/32). +#### Password hashing + +For instructions on how to set a backend specific hasher or use the general one, see [Hashing](#hashing). + #### Testing MongoDB Much like `redis`, to test this backend the plugin needs to be able to connect to a mongodb server located at localhost, on port 27017, without using username or password. diff --git a/backends/db.go b/backends/db.go new file mode 100644 index 0000000..266cbb9 --- /dev/null +++ b/backends/db.go @@ -0,0 +1,31 @@ +package backends + +import ( + "time" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// OpenDatabase opens the database and performs a ping to make sure the +// database is up. +// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server +func OpenDatabase(dsn, engine string) (*sqlx.DB, error) { + + db, err := sqlx.Open(engine, dsn) + if err != nil { + return nil, errors.Wrap(err, "database connection error") + } + + for { + if err = db.Ping(); err != nil { + log.Errorf("ping database error, will retry in 2s: %s", err) + time.Sleep(2 * time.Second) + } else { + break + } + } + + return db, nil +} diff --git a/backends/files.go b/backends/files.go index 409ec3a..1d3a6d1 100644 --- a/backends/files.go +++ b/backends/files.go @@ -6,14 +6,11 @@ import ( "os" "strings" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) -// hashIterations defines the number of hash iterations. -var hashIterations = 100000 - //FileUer keeps a user password and acl records. type FileUser struct { Password string @@ -30,15 +27,15 @@ type AclRecord struct { type Files struct { PasswordPath string AclPath string - SaltEncoding string CheckAcls bool Users map[string]*FileUser //Users keeps a registry of username/FileUser pairs, holding a user's password and Acl records. AclRecords []AclRecord filesOnly bool + hasher hashing.HashComparer } //NewFiles initializes a files backend. -func NewFiles(authOpts map[string]string, logLevel log.Level) (Files, error) { +func NewFiles(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Files, error) { log.SetLevel(logLevel) @@ -48,8 +45,8 @@ func NewFiles(authOpts map[string]string, logLevel log.Level) (Files, error) { CheckAcls: false, Users: make(map[string]*FileUser), AclRecords: make([]AclRecord, 0), - SaltEncoding: "base64", filesOnly: true, + hasher: hasher, } if len(strings.Split(strings.Replace(authOpts["backends"], " ", "", -1), ",")) > 1 { @@ -62,16 +59,6 @@ func NewFiles(authOpts map[string]string, logLevel log.Level) (Files, error) { return files, errors.New("Files backend error: no password path given") } - if saltEncoding, ok := authOpts["salt_encoding"]; ok { - switch saltEncoding { - case common.Base64, common.UTF8: - files.SaltEncoding = saltEncoding - log.Debugf("files backend: set salt encoding to: %s", saltEncoding) - default: - log.Errorf("files backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding) - } - } - if aclPath, ok := authOpts["acl_path"]; ok { files.AclPath = aclPath files.CheckAcls = true @@ -306,7 +293,7 @@ func (o Files) GetUser(username, password, clientid string) bool { return false } - if common.HashCompare(password, fileUser.Password, o.SaltEncoding) { + if o.hasher.Compare(password, fileUser.Password) { return true } @@ -334,7 +321,7 @@ func (o Files) CheckAcl(username, topic, clientid string, acc int32) bool { //If user exists, check against his acls and common ones. If not, check against common acls only. if ok { for _, aclRecord := range fileUser.AclRecords { - if common.TopicsMatch(aclRecord.Topic, topic) && (acc == int32(aclRecord.Acc) || int32(aclRecord.Acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.Acc) == MOSQ_ACL_READ || int32(aclRecord.Acc) == MOSQ_ACL_SUBSCRIBE))) { + if TopicsMatch(aclRecord.Topic, topic) && (acc == int32(aclRecord.Acc) || int32(aclRecord.Acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.Acc) == MOSQ_ACL_READ || int32(aclRecord.Acc) == MOSQ_ACL_SUBSCRIBE))) { return true } } @@ -343,7 +330,7 @@ func (o Files) CheckAcl(username, topic, clientid string, acc int32) bool { //Replace all occurrences of %c for clientid and %u for username aclTopic := strings.Replace(aclRecord.Topic, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if common.TopicsMatch(aclTopic, topic) && (acc == int32(aclRecord.Acc) || int32(aclRecord.Acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.Acc) == MOSQ_ACL_READ || int32(aclRecord.Acc) == MOSQ_ACL_SUBSCRIBE))) { + if TopicsMatch(aclTopic, topic) && (acc == int32(aclRecord.Acc) || int32(aclRecord.Acc) == MOSQ_ACL_READWRITE || (acc == MOSQ_ACL_SUBSCRIBE && topic != "#" && (int32(aclRecord.Acc) == MOSQ_ACL_READ || int32(aclRecord.Acc) == MOSQ_ACL_SUBSCRIBE))) { return true } } diff --git a/backends/files_test.go b/backends/files_test.go index 72c1e9b..b1bf0db 100644 --- a/backends/files_test.go +++ b/backends/files_test.go @@ -4,6 +4,7 @@ import ( "path/filepath" "testing" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" ) @@ -14,7 +15,7 @@ func TestFiles(t *testing.T) { authOpts := make(map[string]string) Convey("Given empty opts NewFiles should fail", t, func() { - _, err := NewFiles(authOpts, log.DebugLevel) + _, err := NewFiles(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "files")) So(err, ShouldBeError) }) @@ -25,7 +26,7 @@ func TestFiles(t *testing.T) { clientID := "test_client" Convey("Given valid params NewFiles should return a new files backend instance", t, func() { - files, err := NewFiles(authOpts, log.DebugLevel) + files, err := NewFiles(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "files")) So(err, ShouldBeNil) /* diff --git a/backends/grpc_test.go b/backends/grpc_test.go index c3da7b8..a6128a3 100644 --- a/backends/grpc_test.go +++ b/backends/grpc_test.go @@ -6,12 +6,10 @@ import ( "testing" "github.com/golang/protobuf/ptypes/empty" - "google.golang.org/grpc" - - log "github.com/sirupsen/logrus" - gs "github.com/iegomez/mosquitto-go-auth/grpc" + log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" + "google.golang.org/grpc" ) const ( diff --git a/backends/http_test.go b/backends/http_test.go index df4f566..52bd914 100644 --- a/backends/http_test.go +++ b/backends/http_test.go @@ -10,7 +10,6 @@ import ( "testing" log "github.com/sirupsen/logrus" - . "github.com/smartystreets/goconvey/convey" ) diff --git a/backends/jwt.go b/backends/jwt.go index 3f58823..9e364d2 100644 --- a/backends/jwt.go +++ b/backends/jwt.go @@ -14,7 +14,8 @@ import ( "strings" "time" - jwt "github.com/dgrijalva/jwt-go" + "github.com/dgrijalva/jwt-go" + "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) @@ -44,6 +45,8 @@ type JWT struct { UserField string Client *h.Client + + hasher hashing.HashComparer } // Claims defines the struct containing the token claims. StandardClaim's Subject field should contain the username, unless an opt is set to support Username field. @@ -58,7 +61,7 @@ type Response struct { Error string `json:"error"` } -func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) { +func NewJWT(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (JWT, error) { log.SetLevel(logLevel) @@ -71,6 +74,7 @@ func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) { ParamsMode: "json", LocalDB: "postgres", UserField: "Subject", + hasher: hasher, } if userField, ok := authOpts["jwt_userfield"]; ok && userField == "Username" { @@ -190,7 +194,7 @@ func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) { if jwt.LocalDB == "mysql" { //Try to create a mysql backend with these custom queries - mysql, err := NewMysql(authOpts, logLevel) + mysql, err := NewMysql(authOpts, logLevel, hasher) if err != nil { return jwt, errors.Errorf("JWT backend error: couldn't create mysql connector for local jwt: %s", err) } @@ -201,7 +205,7 @@ func NewJWT(authOpts map[string]string, logLevel log.Level) (JWT, error) { jwt.Mysql = mysql } else { //Try to create a postgres backend with these custom queries. - postgres, err := NewPostgres(authOpts, logLevel) + postgres, err := NewPostgres(authOpts, logLevel, hasher) if err != nil { return jwt, errors.Errorf("JWT backend error: couldn't create postgres connector for local jwt: %s", err) } diff --git a/backends/jwt_test.go b/backends/jwt_test.go index 53780a4..c33184d 100644 --- a/backends/jwt_test.go +++ b/backends/jwt_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" + "github.com/dgrijalva/jwt-go" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" - - jwt "github.com/dgrijalva/jwt-go" . "github.com/smartystreets/goconvey/convey" ) @@ -73,7 +73,7 @@ func TestLocalPostgresJWT(t *testing.T) { authOpts["pg_password"] = "go_auth_test" Convey("Given correct option NewJWT returns an instance of jwt backend", func() { - jwt, err := NewJWT(authOpts, log.DebugLevel) + jwt, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) //Empty db @@ -222,7 +222,7 @@ func TestLocalMysqlJWT(t *testing.T) { authOpts["mysql_allow_native_passwords"] = "true" Convey("Given correct option NewJWT returns an instance of jwt backend", func() { - jwt, err := NewJWT(authOpts, log.DebugLevel) + jwt, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) //Empty db @@ -451,7 +451,7 @@ func TestJWTAllJsonServer(t *testing.T) { authOpts["jwt_aclcheck_uri"] = "/acl" Convey("Given correct options an http backend instance should be returned", t, func() { - hb, err := NewJWT(authOpts, log.DebugLevel) + hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) Convey("Given correct password/username, get user should return true", func() { @@ -580,7 +580,7 @@ func TestJWTJsonStatusOnlyServer(t *testing.T) { authOpts["jwt_aclcheck_uri"] = "/acl" Convey("Given correct options an http backend instance should be returned", t, func() { - hb, err := NewJWT(authOpts, log.DebugLevel) + hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) Convey("Given correct password/username, get user should return true", func() { @@ -711,7 +711,7 @@ func TestJWTJsonTextResponseServer(t *testing.T) { authOpts["jwt_aclcheck_uri"] = "/acl" Convey("Given correct options an http backend instance should be returned", t, func() { - hb, err := NewJWT(authOpts, log.DebugLevel) + hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) Convey("Given correct password/username, get user should return true", func() { @@ -852,7 +852,7 @@ func TestJWTFormJsonResponseServer(t *testing.T) { authOpts["jwt_aclcheck_uri"] = "/acl" Convey("Given correct options an http backend instance should be returned", t, func() { - hb, err := NewJWT(authOpts, log.DebugLevel) + hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) Convey("Given correct password/username, get user should return true", func() { @@ -975,7 +975,7 @@ func TestJWTFormStatusOnlyServer(t *testing.T) { authOpts["jwt_aclcheck_uri"] = "/acl" Convey("Given correct options an http backend instance should be returned", t, func() { - hb, err := NewJWT(authOpts, log.DebugLevel) + hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) Convey("Given correct password/username, get user should return true", func() { @@ -1101,7 +1101,7 @@ func TestJWTFormTextResponseServer(t *testing.T) { authOpts["jwt_aclcheck_uri"] = "/acl" Convey("Given correct options an http backend instance should be returned", t, func() { - hb, err := NewJWT(authOpts, log.DebugLevel) + hb, err := NewJWT(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "")) So(err, ShouldBeNil) Convey("Given correct password/username, get user should return true", func() { diff --git a/backends/mongo.go b/backends/mongo.go index 99a8095..4174b6c 100644 --- a/backends/mongo.go +++ b/backends/mongo.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "go.mongodb.org/mongo-driver/bson" @@ -26,6 +26,7 @@ type Mongo struct { AclsCollection string Conn *mongo.Client disableSuperuser bool + hasher hashing.HashComparer } type MongoAcl struct { @@ -40,7 +41,7 @@ type MongoUser struct { Acls []MongoAcl `bson:"acls"` } -func NewMongo(authOpts map[string]string, logLevel log.Level) (Mongo, error) { +func NewMongo(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Mongo, error) { log.SetLevel(logLevel) @@ -53,7 +54,7 @@ func NewMongo(authOpts map[string]string, logLevel log.Level) (Mongo, error) { AuthSource: "", UsersCollection: "users", AclsCollection: "acls", - SaltEncoding: "base64", + hasher: hasher, } if authOpts["mongo_disable_superuser"] == "true" { @@ -76,16 +77,6 @@ func NewMongo(authOpts map[string]string, logLevel log.Level) (Mongo, error) { m.Password = mongoPassword } - if saltEncoding, ok := authOpts["mongo_salt_encoding"]; ok { - switch saltEncoding { - case common.Base64, common.UTF8: - m.SaltEncoding = saltEncoding - log.Debugf("mongo backend: set salt encoding to: %s", saltEncoding) - default: - log.Errorf("mongo backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding) - } - } - if mongoDBName, ok := authOpts["mongo_dbname"]; ok { m.DBName = mongoDBName } @@ -149,7 +140,7 @@ func (o Mongo) GetUser(username, password, clientid string) bool { return false } - if common.HashCompare(password, user.PasswordHash, o.SaltEncoding) { + if o.hasher.Compare(password, user.PasswordHash) { return true } @@ -193,7 +184,7 @@ func (o Mongo) CheckAcl(username, topic, clientid string, acc int32) bool { } for _, acl := range user.Acls { - if (acl.Acc == acc || acl.Acc == 3) && common.TopicsMatch(acl.Topic, topic) { + if (acl.Acc == acc || acl.Acc == 3) && TopicsMatch(acl.Topic, topic) { return true } } @@ -216,7 +207,7 @@ func (o Mongo) CheckAcl(username, topic, clientid string, acc int32) bool { if err == nil { aclTopic := strings.Replace(acl.Topic, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if common.TopicsMatch(aclTopic, topic) { + if TopicsMatch(aclTopic, topic) { return true } } else { diff --git a/backends/mongo_test.go b/backends/mongo_test.go index 70ab65c..6167af9 100644 --- a/backends/mongo_test.go +++ b/backends/mongo_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" ) @@ -40,7 +41,7 @@ func TestMongoRaw(t *testing.T) { Convey("Given valid params NewMongo should return a Mongo backend instance", t, func() { - mongo, err := NewMongo(authOpts, log.DebugLevel) + mongo, err := NewMongo(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mongo")) So(err, ShouldBeNil) mongo.Conn.Database(mongo.DBName).Drop(context.TODO()) mongoDb := mongo.Conn.Database(mongo.DBName) @@ -195,11 +196,14 @@ func TestMongoUtf8(t *testing.T) { authOpts["mongo_host"] = mongoHost authOpts["mongo_port"] = mongoPort authOpts["mongo_dbname"] = mongoDbName - authOpts["mongo_salt_encoding"] = "utf-8" + + // Pass explicit hasher so utf-8 salt encoding is used. + authOpts["hasher"] = "pbkdf2" + authOpts["hasher_salt_encoding"] = "utf-8" Convey("Given valid params NewMongo should return a Mongo backend instance", t, func() { - mongo, err := NewMongo(authOpts, log.DebugLevel) + mongo, err := NewMongo(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mongo")) So(err, ShouldBeNil) mongo.Conn.Database(mongo.DBName).Drop(context.TODO()) mongoDb := mongo.Conn.Database(mongo.DBName) diff --git a/backends/mysql.go b/backends/mysql.go index 66778d4..20b71d7 100644 --- a/backends/mysql.go +++ b/backends/mysql.go @@ -9,7 +9,7 @@ import ( "strings" mq "github.com/go-sql-driver/mysql" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/jmoiron/sqlx" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -23,7 +23,6 @@ type Mysql struct { DBName string User string Password string - SaltEncoding string UserQuery string SuperuserQuery string AclQuery string @@ -34,9 +33,10 @@ type Mysql struct { Protocol string SocketPath string AllowNativePasswords bool + hasher hashing.HashComparer } -func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) { +func NewMysql(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Mysql, error) { log.SetLevel(logLevel) @@ -52,7 +52,7 @@ func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) { SuperuserQuery: "", AclQuery: "", Protocol: "tcp", - SaltEncoding: "base64", + hasher: hasher, } if protocol, ok := authOpts["mysql_protocol"]; ok { @@ -92,16 +92,6 @@ func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) { missingOptions += " mysql_password" } - if saltEncoding, ok := authOpts["mysql_salt_encoding"]; ok { - switch saltEncoding { - case common.Base64, common.UTF8: - mysql.SaltEncoding = saltEncoding - log.Debugf("mysql backend: set salt encoding to: %s", saltEncoding) - default: - log.Errorf("mysql backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding) - } - } - if userQuery, ok := authOpts["mysql_userquery"]; ok { mysql.UserQuery = userQuery } else { @@ -201,7 +191,7 @@ func NewMysql(authOpts map[string]string, logLevel log.Level) (Mysql, error) { } var err error - mysql.DB, err = common.OpenDatabase(msConfig.FormatDSN(), "mysql") + mysql.DB, err = OpenDatabase(msConfig.FormatDSN(), "mysql") if err != nil { return mysql, errors.Errorf("MySql backend error: couldn't open db: %s", err) @@ -227,7 +217,7 @@ func (o Mysql) GetUser(username, password, clientid string) bool { return false } - if common.HashCompare(password, pwHash.String, o.SaltEncoding) { + if o.hasher.Compare(password, pwHash.String) { return true } @@ -283,7 +273,7 @@ func (o Mysql) CheckAcl(username, topic, clientid string, acc int32) bool { for _, acl := range acls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if common.TopicsMatch(aclTopic, topic) { + if TopicsMatch(aclTopic, topic) { return true } } diff --git a/backends/mysql_test.go b/backends/mysql_test.go index 3d0ec42..b5900b5 100644 --- a/backends/mysql_test.go +++ b/backends/mysql_test.go @@ -3,6 +3,7 @@ package backends import ( "testing" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" ) @@ -17,7 +18,7 @@ func TestMysql(t *testing.T) { authOpts["mysql_allow_native_passwords"] = "true" Convey("If mandatory params are not set initialization should fail", t, func() { - _, err := NewMysql(authOpts, log.DebugLevel) + _, err := NewMysql(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mysql")) So(err, ShouldBeError) }) @@ -30,7 +31,7 @@ func TestMysql(t *testing.T) { authOpts["mysql_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = ? AND test_acl.test_user_id = test_user.id AND (rw >= ? or rw = 3)" Convey("Given valid params NewMysql should return a Mysql backend instance", t, func() { - mysql, err := NewMysql(authOpts, log.DebugLevel) + mysql, err := NewMysql(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "mysql")) So(err, ShouldBeNil) //Empty db diff --git a/backends/postgres.go b/backends/postgres.go index 92c79b5..8fc43a0 100644 --- a/backends/postgres.go +++ b/backends/postgres.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "github.com/pkg/errors" @@ -20,7 +20,6 @@ type Postgres struct { DBName string User string Password string - SaltEncoding string UserQuery string SuperuserQuery string AclQuery string @@ -28,9 +27,10 @@ type Postgres struct { SSLCert string SSLKey string SSLRootCert string + hasher hashing.HashComparer } -func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, error) { +func NewPostgres(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Postgres, error) { log.SetLevel(logLevel) @@ -45,7 +45,7 @@ func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, erro SSLMode: "disable", SuperuserQuery: "", AclQuery: "", - SaltEncoding: "base64", + hasher: hasher, } if host, ok := authOpts["pg_host"]; ok { @@ -77,16 +77,6 @@ func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, erro missingOptions += " pg_password" } - if saltEncoding, ok := authOpts["pg_salt_encoding"]; ok { - switch saltEncoding { - case common.Base64, common.UTF8: - postgres.SaltEncoding = saltEncoding - log.Debugf("postgres backend: set salt encoding to: %s", saltEncoding) - default: - log.Errorf("postgres backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding) - } - } - if userQuery, ok := authOpts["pg_userquery"]; ok { postgres.UserQuery = userQuery } else { @@ -145,7 +135,7 @@ func NewPostgres(authOpts map[string]string, logLevel log.Level) (Postgres, erro } var err error - postgres.DB, err = common.OpenDatabase(connStr, "postgres") + postgres.DB, err = OpenDatabase(connStr, "postgres") if err != nil { return postgres, errors.Errorf("PG backend error: couldn't open db: %s", err) @@ -171,7 +161,7 @@ func (o Postgres) GetUser(username, password, clientid string) bool { return false } - if common.HashCompare(password, pwHash.String, o.SaltEncoding) { + if o.hasher.Compare(password, pwHash.String) { return true } @@ -228,7 +218,7 @@ func (o Postgres) CheckAcl(username, topic, clientid string, acc int32) bool { for _, acl := range acls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if common.TopicsMatch(aclTopic, topic) { + if TopicsMatch(aclTopic, topic) { return true } } diff --git a/backends/postgres_test.go b/backends/postgres_test.go index 044b0ee..5f8cb2b 100644 --- a/backends/postgres_test.go +++ b/backends/postgres_test.go @@ -3,6 +3,7 @@ package backends import ( "testing" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" ) @@ -15,7 +16,7 @@ func TestPostgres(t *testing.T) { authOpts["pg_port"] = "5432" Convey("If mandatory params are not set initialization should fail", t, func() { - _, err := NewPostgres(authOpts, log.DebugLevel) + _, err := NewPostgres(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "postgres")) So(err, ShouldBeError) }) @@ -28,7 +29,7 @@ func TestPostgres(t *testing.T) { authOpts["pg_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = $1 AND test_acl.test_user_id = test_user.id AND (rw = $2 or rw = 3)" Convey("Given valid params NewPostgres should return a Postgres backend instance", t, func() { - postgres, err := NewPostgres(authOpts, log.DebugLevel) + postgres, err := NewPostgres(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "postgres")) So(err, ShouldBeNil) //Empty db diff --git a/backends/redis.go b/backends/redis.go index 2b0c53d..bd69dfc 100644 --- a/backends/redis.go +++ b/backends/redis.go @@ -9,7 +9,7 @@ import ( "time" goredis "github.com/go-redis/redis/v8" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" ) @@ -44,9 +44,10 @@ type Redis struct { conn RedisClient disableSuperuser bool ctx context.Context + hasher hashing.HashComparer } -func NewRedis(authOpts map[string]string, logLevel log.Level) (Redis, error) { +func NewRedis(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Redis, error) { log.SetLevel(logLevel) @@ -56,6 +57,7 @@ func NewRedis(authOpts map[string]string, logLevel log.Level) (Redis, error) { DB: 1, SaltEncoding: "base64", ctx: context.Background(), + hasher: hasher, } if authOpts["redis_disable_superuser"] == "true" { @@ -74,16 +76,6 @@ func NewRedis(authOpts map[string]string, logLevel log.Level) (Redis, error) { redis.Password = redisPassword } - if saltEncoding, ok := authOpts["redis_salt_encoding"]; ok { - switch saltEncoding { - case common.Base64, common.UTF8: - redis.SaltEncoding = saltEncoding - log.Debugf("redis backend: set salt encoding to: %s", saltEncoding) - default: - log.Errorf("redis backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding) - } - } - if redisDB, ok := authOpts["redis_db"]; ok { db, err := strconv.ParseInt(redisDB, 10, 32) if err == nil { @@ -175,7 +167,7 @@ func (o Redis) getUser(username, password string) (bool, error) { return false, err } - if common.HashCompare(password, pwHash, o.SaltEncoding) { + if o.hasher.Compare(password, pwHash) { return true, nil } @@ -330,7 +322,7 @@ func (o Redis) checkAcl(username, topic, clientid string, acc int32) (bool, erro //Now loop through acls looking for a match. for _, acl := range acls { - if common.TopicsMatch(acl, topic) { + if TopicsMatch(acl, topic) { return true, nil } } @@ -338,7 +330,7 @@ func (o Redis) checkAcl(username, topic, clientid string, acc int32) (bool, erro for _, acl := range commonAcls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if common.TopicsMatch(aclTopic, topic) { + if TopicsMatch(aclTopic, topic) { return true, nil } } diff --git a/backends/redis_test.go b/backends/redis_test.go index f7ee547..d46b0ed 100644 --- a/backends/redis_test.go +++ b/backends/redis_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -35,7 +36,7 @@ func TestRedisCluster(t *testing.T) { } func testRedis(ctx context.Context, t *testing.T, authOpts map[string]string) { - redis, err := NewRedis(authOpts, log.DebugLevel) + redis, err := NewRedis(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "redis")) assert.Nil(t, err) //Empty db diff --git a/backends/sqlite.go b/backends/sqlite.go index f133cc2..edcef67 100644 --- a/backends/sqlite.go +++ b/backends/sqlite.go @@ -4,7 +4,7 @@ import ( "database/sql" "strings" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/pkg/errors" @@ -18,10 +18,10 @@ type Sqlite struct { UserQuery string SuperuserQuery string AclQuery string - SaltEncoding string + hasher hashing.HashComparer } -func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) { +func NewSqlite(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer) (Sqlite, error) { log.SetLevel(logLevel) @@ -33,7 +33,7 @@ func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) { var sqlite = Sqlite{ SuperuserQuery: "", AclQuery: "", - SaltEncoding: "base64", + hasher: hasher, } if source, ok := authOpts["sqlite_source"]; ok { @@ -58,16 +58,6 @@ func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) { sqlite.AclQuery = aclQuery } - if saltEncoding, ok := authOpts["sqlite_salt_encoding"]; ok { - switch saltEncoding { - case common.Base64, common.UTF8: - sqlite.SaltEncoding = saltEncoding - log.Debugf("sqlite backend: set salt encoding to: %s", saltEncoding) - default: - log.Errorf("sqlite backend: invalid salt encoding specified: %s, will default to base64 instead", saltEncoding) - } - } - //Exit if any mandatory option is missing. if !sqliteOk { return sqlite, errors.Errorf("sqlite backend error: missing options: %s", missingOptions) @@ -80,7 +70,7 @@ func NewSqlite(authOpts map[string]string, logLevel log.Level) (Sqlite, error) { } var err error - sqlite.DB, err = common.OpenDatabase(connStr, "sqlite3") + sqlite.DB, err = OpenDatabase(connStr, "sqlite3") if err != nil { return sqlite, errors.Errorf("sqlite backend error: couldn't open db %s: %s", connStr, err) @@ -106,7 +96,7 @@ func (o Sqlite) GetUser(username, password, clientid string) bool { return false } - if common.HashCompare(password, pwHash.String, o.SaltEncoding) { + if o.hasher.Compare(password, pwHash.String) { return true } @@ -162,7 +152,7 @@ func (o Sqlite) CheckAcl(username, topic, clientid string, acc int32) bool { for _, acl := range acls { aclTopic := strings.Replace(acl, "%c", clientid, -1) aclTopic = strings.Replace(aclTopic, "%u", username, -1) - if common.TopicsMatch(aclTopic, topic) { + if TopicsMatch(aclTopic, topic) { return true } } diff --git a/backends/sqlite_test.go b/backends/sqlite_test.go index 5e36f59..493c12e 100644 --- a/backends/sqlite_test.go +++ b/backends/sqlite_test.go @@ -4,8 +4,8 @@ import ( "os" "testing" + "github.com/iegomez/mosquitto-go-auth/hashing" log "github.com/sirupsen/logrus" - . "github.com/smartystreets/goconvey/convey" ) @@ -35,7 +35,7 @@ func TestFileSqlite(t *testing.T) { authOpts := make(map[string]string) Convey("If mandatory params are not set initialization should fail", t, func() { - _, err := NewSqlite(authOpts, log.DebugLevel) + _, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite")) So(err, ShouldBeError) }) @@ -56,7 +56,7 @@ func TestFileSqlite(t *testing.T) { authOpts["sqlite_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = ? AND test_acl.test_user_id = test_user.id AND rw >= ?" Convey("Given valid params NewSqlite should return a Sqlite backend instance", t, func() { - sqlite, err := NewSqlite(authOpts, log.DebugLevel) + sqlite, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite")) So(err, ShouldBeNil) //Create schemas @@ -213,7 +213,7 @@ func TestMemorySqlite(t *testing.T) { authOpts := make(map[string]string) Convey("If mandatory params are not set initialization should fail", t, func() { - _, err := NewSqlite(authOpts, log.DebugLevel) + _, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite")) So(err, ShouldBeError) }) @@ -224,7 +224,7 @@ func TestMemorySqlite(t *testing.T) { authOpts["sqlite_aclquery"] = "SELECT test_acl.topic FROM test_acl, test_user WHERE test_user.username = ? AND test_acl.test_user_id = test_user.id AND rw >= ?" Convey("Given valid params NewSqlite should return a Sqlite backend instance", t, func() { - sqlite, err := NewSqlite(authOpts, log.DebugLevel) + sqlite, err := NewSqlite(authOpts, log.DebugLevel, hashing.NewHasher(authOpts, "sqlite")) So(err, ShouldBeNil) //Create schemas diff --git a/backends/topic.go b/backends/topic.go new file mode 100644 index 0000000..c544d81 --- /dev/null +++ b/backends/topic.go @@ -0,0 +1,22 @@ +package backends + +import "strings" + +func TopicsMatch(savedTopic, givenTopic string) bool { + return givenTopic == savedTopic || match(strings.Split(savedTopic, "/"), strings.Split(givenTopic, "/")) +} + +func match(route []string, topic []string) bool { + switch { + case len(route) == 0: + return len(topic) == 0 + case len(topic) == 0: + return route[0] == "#" + case route[0] == "#": + return true + case route[0] == "+", route[0] == topic[0]: + return match(route[1:], topic[1:]) + } + + return false +} diff --git a/cache/cache.go b/cache/cache.go index be135aa..3ecf1c4 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -2,8 +2,10 @@ package cache import ( "context" + "crypto/sha1" b64 "encoding/base64" "fmt" + "hash" "strings" "time" @@ -18,12 +20,14 @@ type redisStore struct { authExpiration time.Duration aclExpiration time.Duration client bes.RedisClient + h hash.Hash } type goStore struct { authExpiration time.Duration aclExpiration time.Duration client *goCache.Cache + h hash.Hash } const ( @@ -47,6 +51,7 @@ func NewGoStore(authExpiration, aclExpiration time.Duration) *goStore { authExpiration: authExpiration, aclExpiration: aclExpiration, client: goCache.New(time.Second*defaultExpiration, time.Second*(defaultExpiration*2)), + h: sha1.New(), } } @@ -63,6 +68,7 @@ func NewSingleRedisStore(host, port, password string, db int, authExpiration, ac authExpiration: authExpiration, aclExpiration: aclExpiration, client: bes.SingleRedisClient{redisClient}, + h: sha1.New(), } } @@ -78,15 +84,20 @@ func NewRedisClusterStore(password string, addresses []string, authExpiration, a authExpiration: authExpiration, aclExpiration: aclExpiration, client: clusterClient, + h: sha1.New(), } } -func toAuthRecord(username, password string) string { - return b64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("auth-%s-%s", username, password))) +func toAuthRecord(username, password string, h hash.Hash) string { + sum := h.Sum([]byte(fmt.Sprintf("auth-%s-%s", username, password))) + log.Debugf("to auth record: %v\n", sum) + return b64.StdEncoding.EncodeToString(sum) } -func toACLRecord(username, topic, clientid string, acc int) string { - return b64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("acl-%s-%s-%s-%d", username, topic, clientid, acc))) +func toACLRecord(username, topic, clientid string, acc int, h hash.Hash) string { + sum := h.Sum([]byte(fmt.Sprintf("acl-%s-%s-%s-%d", username, topic, clientid, acc))) + log.Debugf("to auth record: %v\n", sum) + return b64.StdEncoding.EncodeToString(sum) } // Checks if an error was caused by a moved record in a Redis Cluster. @@ -136,13 +147,13 @@ func (s *redisStore) Close() { // CheckAuthRecord checks if the username/password pair is present in the cache. Return if it's present and, if so, if it was granted privileges func (s *goStore) CheckAuthRecord(ctx context.Context, username, password string) (bool, bool) { - record := toAuthRecord(username, password) + record := toAuthRecord(username, password, s.h) return s.checkRecord(ctx, record, s.authExpiration) } //CheckAclCache checks if the username/topic/clientid/acc mix is present in the cache. Return if it's present and, if so, if it was granted privileges. func (s *goStore) CheckACLRecord(ctx context.Context, username, topic, clientid string, acc int) (bool, bool) { - record := toACLRecord(username, topic, clientid, acc) + record := toACLRecord(username, topic, clientid, acc, s.h) return s.checkRecord(ctx, record, s.aclExpiration) } @@ -163,13 +174,13 @@ func (s *goStore) checkRecord(ctx context.Context, record string, expirationTime // CheckAuthRecord checks if the username/password pair is present in the cache. Return if it's present and, if so, if it was granted privileges func (s *redisStore) CheckAuthRecord(ctx context.Context, username, password string) (bool, bool) { - record := toAuthRecord(username, password) + record := toAuthRecord(username, password, s.h) return s.checkRecord(ctx, record, s.authExpiration) } //CheckAclCache checks if the username/topic/clientid/acc mix is present in the cache. Return if it's present and, if so, if it was granted privileges. func (s *redisStore) CheckACLRecord(ctx context.Context, username, topic, clientid string, acc int) (bool, bool) { - record := toACLRecord(username, topic, clientid, acc) + record := toACLRecord(username, topic, clientid, acc, s.h) return s.checkRecord(ctx, record, s.aclExpiration) } @@ -219,7 +230,7 @@ func (s *redisStore) getAndRefresh(ctx context.Context, record string, expiratio // SetAuthRecord sets a pair, granted option and expiration time. func (s *goStore) SetAuthRecord(ctx context.Context, username, password string, granted string) error { - record := toAuthRecord(username, password) + record := toAuthRecord(username, password, s.h) s.client.Set(record, granted, time.Second*time.Duration(s.authExpiration)) return nil @@ -227,7 +238,7 @@ func (s *goStore) SetAuthRecord(ctx context.Context, username, password string, //SetAclCache sets a mix, granted option and expiration time. func (s *goStore) SetACLRecord(ctx context.Context, username, topic, clientid string, acc int, granted string) error { - record := toACLRecord(username, topic, clientid, acc) + record := toACLRecord(username, topic, clientid, acc, s.h) s.client.Set(record, granted, time.Second*time.Duration(s.authExpiration)) return nil @@ -235,13 +246,13 @@ func (s *goStore) SetACLRecord(ctx context.Context, username, topic, clientid st // SetAuthRecord sets a pair, granted option and expiration time. func (s *redisStore) SetAuthRecord(ctx context.Context, username, password string, granted string) error { - record := toAuthRecord(username, password) + record := toAuthRecord(username, password, s.h) return s.setRecord(ctx, record, granted, s.authExpiration) } //SetAclCache sets a mix, granted option and expiration time. func (s *redisStore) SetACLRecord(ctx context.Context, username, topic, clientid string, acc int, granted string) error { - record := toACLRecord(username, topic, clientid, acc) + record := toACLRecord(username, topic, clientid, acc, s.h) return s.setRecord(ctx, record, granted, s.authExpiration) } diff --git a/common/utils.go b/common/utils.go deleted file mode 100644 index eb2bd73..0000000 --- a/common/utils.go +++ /dev/null @@ -1,186 +0,0 @@ -package common - -import ( - "bytes" - "crypto/rand" - "crypto/sha256" - "crypto/sha512" - "encoding/base64" - "fmt" - "strconv" - "strings" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/pkg/errors" - "golang.org/x/crypto/pbkdf2" - - "github.com/jmoiron/sqlx" -) - -// Declare the valid encodings for validation. -const ( - UTF8 = "utf-8" - Base64 = "base64" -) - -// OpenDatabase opens the database and performs a ping to make sure the -// database is up. -// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server -func OpenDatabase(dsn, engine string) (*sqlx.DB, error) { - - db, err := sqlx.Open(engine, dsn) - if err != nil { - return nil, errors.Wrap(err, "database connection error") - } - - for { - if err = db.Ping(); err != nil { - log.Errorf("ping database error, will retry in 2s: %s", err) - time.Sleep(2 * time.Second) - } else { - break - } - } - - return db, nil -} - -func TopicsMatch(savedTopic, givenTopic string) bool { - return givenTopic == savedTopic || match(strings.Split(savedTopic, "/"), strings.Split(givenTopic, "/")) -} - -func match(route []string, topic []string) bool { - if len(route) == 0 { - if len(topic) == 0 { - return true - } - return false - } - - if len(topic) == 0 { - if route[0] == "#" { - return true - } - return false - } - - if route[0] == "#" { - return true - } - - if (route[0] == "+") || (route[0] == topic[0]) { - return match(route[1:], topic[1:]) - } - - return false -} - -/* -* PBKDF2 passwords usage taken from github.com/brocaar/lora-app-server, comments included. - */ - -// Generate the hash of a password for storage in the database. -// NOTE: We store the details of the hashing algorithm with the hash itself, -// making it easy to recreate the hash for password checking, even if we change -// the default criteria here. -// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server -func Hash(password string, saltSize int, iterations int, algorithm string, saltEncoding string, keylen int) (string, error) { - // Generate a random salt value, 128 bits. - salt := make([]byte, saltSize) - _, err := rand.Read(salt) - if err != nil { - return "", errors.Wrap(err, "read random bytes error") - } - - return hashWithSalt(password, salt, iterations, algorithm, saltEncoding, keylen), nil -} - -// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server -func hashWithSalt(password string, salt []byte, iterations int, algorithm string, saltEncoding string, keylen int) string { - // Generate the hash. This should be a little painful, adjust ITERATIONS - // if it needs performance tweeking. Greatly depends on the hardware. - // NOTE: We store these details with the returned hash, so changes will not - // affect our ability to do password compares. - shaHash := sha512.New - if algorithm == "sha256" { - shaHash = sha256.New - } - hash := pbkdf2.Key([]byte(password), salt, iterations, keylen, shaHash) - - // Build up the parameters and hash into a single string so we can compare - // other string to the same hash. Note that the hash algorithm is hard- - // coded here, as it is above. Introducing alternate encodings must support - // old encodings as well, and build this string appropriately. - var buffer bytes.Buffer - - buffer.WriteString("PBKDF2$") - buffer.WriteString(fmt.Sprintf("%s$", algorithm)) - buffer.WriteString(strconv.Itoa(iterations)) - buffer.WriteString("$") - // Re-encode salt, using encoding supplied in saltEncoding param - switch saltEncoding { - case UTF8: - buffer.WriteString(string(salt)) - case Base64: - buffer.WriteString(base64.StdEncoding.EncodeToString(salt)) - default: - log.Errorf("Supplied saltEncoding not supported: %s, defaulting to base64", saltEncoding) - buffer.WriteString(base64.StdEncoding.EncodeToString(salt)) - } - buffer.WriteString("$") - buffer.WriteString(base64.StdEncoding.EncodeToString(hash)) - //log.Debugf("Generated: ", buffer.String()) - return buffer.String() -} - -// HashCompare verifies that passed password hashes to the same value as the -// passed passwordHash. -// Taken from brocaar's lora-app-server: https://github.com/brocaar/lora-app-server -func HashCompare(password string, passwordHash string, saltEncoding string) bool { - // Split the hash string into its parts. - hashSplit := strings.Split(passwordHash, "$") - // Check array is of expected length - if len(hashSplit) != 5 { - log.Errorf("HashCompare, invalid PBKDF2 hash supplied.") - return false - } - // Get the iterations from PBKDF2 string - iterations, err := strconv.Atoi(hashSplit[2]) - if err != nil { - log.Errorf("Error getting number of iterations from PBKDF2 hash.") - return false - } - // Convert salt to bytes, using encoding supplied in saltEncoding param - salt := []byte{} - switch saltEncoding { - case UTF8: - salt = []byte(hashSplit[3]) - case Base64: - salt, err = base64.StdEncoding.DecodeString(hashSplit[3]) - if err != nil { - log.Errorf("Error decoding supplied base64 salt.") - return false - } - default: - log.Errorf("Supplied saltEncoding not supported: %s, defaulting to base64", saltEncoding) - salt, err = base64.StdEncoding.DecodeString(hashSplit[3]) - if err != nil { - log.Errorf("Error decoding supplied base64 salt.") - return false - } - } - // Work out key length, assumes base64 encoding - hash, err := base64.StdEncoding.DecodeString(hashSplit[4]) - if err != nil { - log.Errorf("Error decoding supplied base64 hash.") - return false - } - keylen := len(hash) - // Get the algorithm from PBKDF2 string - algorithm := hashSplit[1] - // Generate new PBKDF2 hash to compare against supplied PBKDF2 string - newHash := hashWithSalt(password, salt, iterations, algorithm, saltEncoding, keylen) - return newHash == passwordHash -} diff --git a/go-auth.go b/go-auth.go index 4e3be11..62e4d93 100644 --- a/go-auth.go +++ b/go-auth.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/iegomez/mosquitto-go-auth/hashing" + bes "github.com/iegomez/mosquitto-go-auth/backends" "github.com/iegomez/mosquitto-go-auth/cache" log "github.com/sirupsen/logrus" @@ -41,6 +43,7 @@ type AuthPlugin struct { disableSuperuser bool ctx context.Context cache cache.Store + hasher hashing.HashComparer } const ( @@ -83,7 +86,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends := make(map[string]Backend) - //Initialize common struct with default and given values + //Initialize auth plugin struct with default and given values. authPlugin = AuthPlugin{ checkPrefix: false, prefixes: make(map[string]string), @@ -251,9 +254,10 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { } } else { + hasher := hashing.NewHasher(authOpts, bename) switch bename { case postgresBackend: - beIface, err = bes.NewPostgres(authOpts, authPlugin.logLevel) + beIface, err = bes.NewPostgres(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { @@ -261,7 +265,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends[postgresBackend] = beIface.(bes.Postgres) } case jwtBackend: - beIface, err = bes.NewJWT(authOpts, authPlugin.logLevel) + beIface, err = bes.NewJWT(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { @@ -269,7 +273,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends[jwtBackend] = beIface.(bes.JWT) } case filesBackend: - beIface, err = bes.NewFiles(authOpts, authPlugin.logLevel) + beIface, err = bes.NewFiles(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { @@ -277,7 +281,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends[filesBackend] = beIface.(bes.Files) } case redisBackend: - beIface, err = bes.NewRedis(authOpts, authPlugin.logLevel) + beIface, err = bes.NewRedis(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { @@ -285,7 +289,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends[redisBackend] = beIface.(bes.Redis) } case mysqlBackend: - beIface, err = bes.NewMysql(authOpts, authPlugin.logLevel) + beIface, err = bes.NewMysql(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { @@ -301,7 +305,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends[httpBackend] = beIface.(bes.HTTP) } case sqliteBackend: - beIface, err = bes.NewSqlite(authOpts, authPlugin.logLevel) + beIface, err = bes.NewSqlite(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { @@ -309,7 +313,7 @@ func AuthPluginInit(keys []string, values []string, authOptsNum int) { cmBackends[sqliteBackend] = beIface.(bes.Sqlite) } case mongoBackend: - beIface, err = bes.NewMongo(authOpts, authPlugin.logLevel) + beIface, err = bes.NewMongo(authOpts, authPlugin.logLevel, hasher) if err != nil { log.Fatalf("Backend register error: couldn't initialize %s backend with error %s.", bename, err) } else { diff --git a/hashing/argon2id.go b/hashing/argon2id.go new file mode 100644 index 0000000..d7a417f --- /dev/null +++ b/hashing/argon2id.go @@ -0,0 +1,107 @@ +package hashing + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strconv" + "strings" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/argon2" +) + +type argon2IDHasher struct { + saltSize int + iterations int + keyLen int + memory uint32 + parallelism uint8 +} + +func NewArgon2IDHasher(saltSize int, iterations int, keylen int, memory uint32, parallelism uint8) HashComparer { + return argon2IDHasher{ + saltSize: saltSize, + iterations: iterations, + keyLen: keylen, + memory: memory, + parallelism: parallelism, + } +} + +// Hash generates a hashed password using Argon2ID. +func (h argon2IDHasher) Hash(password string) (string, error) { + salt := make([]byte, h.saltSize) + _, err := rand.Read(salt) + if err != nil { + return "", errors.Wrap(err, "read random bytes error") + } + + return h.hashWithSalt(password, salt, h.memory, h.iterations, h.parallelism, h.keyLen), nil +} + +// Compare checks that an argon2 generated password matches the password hash. +func (h argon2IDHasher) Compare(password string, passwordHash string) bool { + hashSplit := strings.Split(passwordHash, "$") + + if hashSplit[1] != "argon2id" { + log.Errorf("unknown hash format: %s", hashSplit[1]) + } + + if len(hashSplit) != 6 { + log.Errorf("invalid hash supplied, expected 6 elements, got: %d", len(hashSplit)) + return false + } + + version, err := strconv.ParseInt(strings.TrimPrefix(hashSplit[2], "v="), 10, 32) + if err != nil { + log.Errorf("argon2id version parse error: %s", err) + return false + } + + if version != argon2.Version { + log.Errorf("unknown argon2id version: %d", version) + return false + } + + var memory, iterations uint32 + var parallelism uint8 + _, err = fmt.Sscanf(hashSplit[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism) + if err != nil { + log.Errorf("argon2id parameters parse error: %s", err) + return false + } + + salt, err := base64.RawStdEncoding.DecodeString(hashSplit[4]) + if err != nil { + log.Errorf("base64 salt error: %s", err) + return false + } + + extractedHash, err := base64.RawStdEncoding.DecodeString(hashSplit[5]) + if err != nil { + log.Errorf("argon2id decoding error: %s", err) + return false + } + + keylen := uint32(len(extractedHash)) + newHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keylen) + + if subtle.ConstantTimeCompare(newHash, extractedHash) == 1 { + return true + } + + return false +} + +func (h argon2IDHasher) hashWithSalt(password string, salt []byte, memory uint32, iterations int, parallelism uint8, keylen int) string { + + hashedPassword := argon2.IDKey([]byte(password), salt, uint32(iterations), memory, parallelism, uint32(keylen)) + + b64salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hashedPassword) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.memory, h.iterations, h.parallelism, b64salt, b64Hash) +} diff --git a/hashing/bcrypt.go b/hashing/bcrypt.go new file mode 100644 index 0000000..0b416a1 --- /dev/null +++ b/hashing/bcrypt.go @@ -0,0 +1,30 @@ +package hashing + +import ( + "golang.org/x/crypto/bcrypt" +) + +type bcryptHasher struct { + cost int +} + +func NewBcryptHashComparer(cost int) HashComparer { + return bcryptHasher{ + cost: cost, + } +} + +// Hash generates a hashed password using bcrypt. +func (h bcryptHasher) Hash(password string) (string, error) { + generated, err := bcrypt.GenerateFromPassword([]byte(password), h.cost) + return string(generated), err +} + +// Compare checks that a bcrypt generated password matches the password hash. +func (h bcryptHasher) Compare(password, passwordHash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) + if err != nil { + return false + } + return true +} diff --git a/hashing/hashing.go b/hashing/hashing.go new file mode 100644 index 0000000..3d17746 --- /dev/null +++ b/hashing/hashing.go @@ -0,0 +1,150 @@ +package hashing + +import ( + "fmt" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + // algorithms + SHA512 = "sha512" + SHA256 = "sha256" + SHA256Size = 32 + SHA512Size = 64 + + // encodings + UTF8 = "utf-8" + Base64 = "base64" + + // hashers + Pbkdf2Opt = "pbkdf2" + Argon2IDOpt = "argon2ID" + BcryptOpt = "bcrypt" + + // defaults + defaultBcryptCost = 10 + + defaultArgon2IDSaltSize = 16 + defaultArgon2IDMemory uint32 = 4096 + defaultArgon2IDIterations = 3 + defaultArgon2IDParallelism uint8 = 2 + defaultArgon2IDKeyLen = 32 + + defaultPBKDF2SaltSize = 16 + defaultPBKDF2Iterations = 100000 + defaultPBKDF2KeyLen = 32 + defaultPBKDF2Algorithm = SHA512 +) + +var saltEncodings = map[string]struct{}{ + UTF8: {}, + Base64: {}, +} + +type HashComparer interface { + Hash(password string) (string, error) + Compare(password, passwordHash string) bool +} + +func preferredEncoding(saltEncoding string) string { + preferredEncoding := Base64 + if _, ok := saltEncodings[saltEncoding]; ok { + preferredEncoding = saltEncoding + } + return preferredEncoding +} + +// Process hash opts: + +// Empty backend: use whatever plugin wise hashing options are present by returning whole opts. +// Backend present: check if there's a backend_hasher option: +// - Yes: return a new map with whatever hashing options are present for the given backend and hasher +// (defaults will be used for missing options). +// - No: use whatever plugin wise hashing options are present by returning whole opts. +func processHashOpts(authOpts map[string]string, backend string) map[string]string { + + // Return authOpts if no backend given. + if backend == "" { + return authOpts + } + // Return authOpts if no hasher was passed for the backend. + if _, ok := authOpts[fmt.Sprintf("%s_hasher", backend)]; !ok { + return authOpts + } + // Extract specific backend options. + hashOpts := make(map[string]string) + for k, v := range authOpts { + if strings.Contains(k, backend) { + hashOpts[strings.TrimPrefix(k, backend+"_")] = v + } + } + return hashOpts +} + +// NewHasher returns a hasher depending on the given options. +func NewHasher(authOpts map[string]string, backend string) HashComparer { + opts := processHashOpts(authOpts, backend) + + switch opts["hasher"] { + case BcryptOpt: + log.Debugf("new hasher: %s", BcryptOpt) + cost, err := strconv.ParseInt(opts["hasher_cost"], 10, 64) + if err != nil { + return NewBcryptHashComparer(defaultBcryptCost) + } + return NewBcryptHashComparer(int(cost)) + case Argon2IDOpt: + log.Debugf("new hasher: %s", Argon2IDOpt) + saltSize := defaultArgon2IDSaltSize + if v, err := strconv.ParseInt(opts["hasher_salt_size"], 10, 64); err == nil { + saltSize = int(v) + } + memory := defaultArgon2IDMemory + if v, err := strconv.ParseUint(opts["hasher_memory"], 10, 32); err == nil { + memory = uint32(v) + } + iterations := defaultArgon2IDIterations + if v, err := strconv.ParseInt(opts["hasher_iterations"], 10, 64); err == nil { + iterations = int(v) + } + parallelism := defaultArgon2IDParallelism + if v, err := strconv.ParseUint(opts["hasher_parallelism"], 10, 8); err == nil { + parallelism = uint8(v) + } + keyLen := defaultArgon2IDKeyLen + if v, err := strconv.ParseInt(opts["hasher_keylen"], 10, 64); err == nil { + keyLen = int(v) + } + return NewArgon2IDHasher(saltSize, iterations, keyLen, memory, parallelism) + case Pbkdf2Opt: + log.Debugf("new hasher: %s", Pbkdf2Opt) + default: + log.Warnln("unknown or empty hasher, defaulting to PBKDF2") + } + + saltSize := defaultPBKDF2SaltSize + if v, err := strconv.ParseInt(opts["hasher_salt_size"], 10, 64); err == nil { + saltSize = int(v) + } + + iterations := defaultPBKDF2Iterations + if v, err := strconv.ParseInt(opts["hasher_iterations"], 10, 64); err == nil { + iterations = int(v) + } + keyLen := defaultPBKDF2KeyLen + if v, err := strconv.ParseInt(opts["hasher_keylen"], 10, 64); err == nil { + keyLen = int(v) + } + algorithm := defaultPBKDF2Algorithm + if opts["hasher_algorithm"] == "sha256" { + algorithm = SHA256 + } + + saltEncoding := opts["hasher_salt_encoding"] + return NewPBKDF2Hasher(saltSize, iterations, algorithm, saltEncoding, keyLen) + + return nil +} diff --git a/hashing/hashing_test.go b/hashing/hashing_test.go new file mode 100644 index 0000000..e4a1cb1 --- /dev/null +++ b/hashing/hashing_test.go @@ -0,0 +1,147 @@ +package hashing + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewHasher(t *testing.T) { + authOpts := make(map[string]string) + + hasher := NewHasher(authOpts, "") + + _, ok := hasher.(pbkdf2Hasher) + assert.True(t, ok) + + authOpts = make(map[string]string) + authOpts["hasher"] = Pbkdf2Opt + hasher = NewHasher(authOpts, "") + + pHasher, ok := hasher.(pbkdf2Hasher) + + assert.True(t, ok) + assert.Equal(t, defaultPBKDF2Algorithm, pHasher.algorithm) + assert.Equal(t, defaultPBKDF2KeyLen, pHasher.keyLen) + assert.Equal(t, defaultPBKDF2Iterations, pHasher.iterations) + assert.Equal(t, defaultPBKDF2SaltSize, pHasher.saltSize) + assert.Equal(t, Base64, pHasher.saltEncoding) + + // Check that options are set correctly. + authOpts = make(map[string]string) + authOpts = map[string]string{ + "hasher": Pbkdf2Opt, + "hasher_algorithm": SHA256, + "hasher_keylen": "24", + "hasher_iterations": "100", + "hasher_salt_size": "30", + "hasher_salt_encoding": UTF8, + } + hasher = NewHasher(authOpts, "") + + pHasher, ok = hasher.(pbkdf2Hasher) + assert.True(t, ok) + assert.Equal(t, SHA256, pHasher.algorithm) + assert.Equal(t, 24, pHasher.keyLen) + assert.Equal(t, 100, pHasher.iterations) + assert.Equal(t, 30, pHasher.saltSize) + assert.Equal(t, UTF8, pHasher.saltEncoding) + + authOpts = make(map[string]string) + authOpts["hasher"] = Argon2IDOpt + hasher = NewHasher(authOpts, "") + + aHasher, ok := hasher.(argon2IDHasher) + + assert.True(t, ok) + assert.Equal(t, defaultArgon2IDIterations, aHasher.iterations) + assert.Equal(t, defaultArgon2IDKeyLen, aHasher.keyLen) + assert.Equal(t, defaultArgon2IDMemory, aHasher.memory) + assert.Equal(t, defaultArgon2IDParallelism, aHasher.parallelism) + assert.Equal(t, defaultArgon2IDSaltSize, aHasher.saltSize) + + authOpts = make(map[string]string) + authOpts = map[string]string{ + "hasher": Argon2IDOpt, + "hasher_iterations": "100", + "hasher_keylen": "24", + "hasher_memory": "1024", + "hasher_parallelism": "4", + "hasher_salt_size": "24", + } + hasher = NewHasher(authOpts, "") + + aHasher, ok = hasher.(argon2IDHasher) + + assert.True(t, ok) + assert.Equal(t, 100, aHasher.iterations) + assert.Equal(t, 24, aHasher.keyLen) + assert.Equal(t, uint32(1024), aHasher.memory) + assert.Equal(t, uint8(4), aHasher.parallelism) + assert.Equal(t, 24, aHasher.saltSize) + + authOpts = make(map[string]string) + authOpts["hasher"] = BcryptOpt + hasher = NewHasher(authOpts, "") + + bHasher, ok := hasher.(bcryptHasher) + assert.True(t, ok) + assert.Equal(t, bHasher.cost, defaultBcryptCost) + + // Check that options are set correctly. + authOpts = make(map[string]string) + authOpts = map[string]string{ + "hasher": BcryptOpt, + "hasher_cost": "15", + } + hasher = NewHasher(authOpts, "") + + bHasher, ok = hasher.(bcryptHasher) + assert.True(t, ok) + assert.Equal(t, 15, bHasher.cost) +} + +func TestBcrypt(t *testing.T) { + + password := "test-password" + hasher := NewBcryptHashComparer(10) + + passwordHash, err := hasher.Hash(password) + + assert.Nil(t, err) + assert.True(t, hasher.Compare(password, passwordHash)) + assert.False(t, hasher.Compare("other", passwordHash)) +} + +func TestArgon2ID(t *testing.T) { + password := "test-password" + hasher := NewArgon2IDHasher(defaultArgon2IDSaltSize, defaultArgon2IDIterations, defaultArgon2IDKeyLen, defaultArgon2IDMemory, defaultArgon2IDParallelism) + + passwordHash, err := hasher.Hash(password) + + assert.Nil(t, err) + assert.True(t, hasher.Compare(password, passwordHash)) + assert.False(t, hasher.Compare("other", passwordHash)) +} + +func TestPBKDF2(t *testing.T) { + password := "test-password" + + // Test base64. + hasher := NewPBKDF2Hasher(defaultPBKDF2SaltSize, defaultPBKDF2Iterations, defaultPBKDF2Algorithm, Base64, defaultPBKDF2KeyLen) + + passwordHash, err := hasher.Hash(password) + + assert.Nil(t, err) + assert.True(t, hasher.Compare(password, passwordHash)) + assert.False(t, hasher.Compare("other", passwordHash)) + + // Test UTF8. + hasher = NewPBKDF2Hasher(defaultPBKDF2SaltSize, defaultPBKDF2Iterations, defaultPBKDF2Algorithm, UTF8, defaultPBKDF2KeyLen) + + passwordHash, err = hasher.Hash(password) + + assert.Nil(t, err) + assert.True(t, hasher.Compare(password, passwordHash)) + assert.False(t, hasher.Compare("other", passwordHash)) +} diff --git a/hashing/pbkdf2.go b/hashing/pbkdf2.go new file mode 100644 index 0000000..bc35253 --- /dev/null +++ b/hashing/pbkdf2.go @@ -0,0 +1,145 @@ +package hashing + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "fmt" + "math/big" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/pbkdf2" +) + +type pbkdf2Hasher struct { + saltSize int + iterations int + algorithm string + saltEncoding string + keyLen int +} + +func NewPBKDF2Hasher(saltSize int, iterations int, algorithm string, saltEncoding string, keylen int) HashComparer { + return pbkdf2Hasher{ + saltSize: saltSize, + iterations: iterations, + algorithm: algorithm, + saltEncoding: preferredEncoding(saltEncoding), + keyLen: keylen, + } +} + +/* +* PBKDF2 methods are adapted from github.com/brocaar/chirpstack-application-server, some comments included. + */ + +// Hash function reference may be found at https://github.com/brocaar/chirpstack-application-server/blob/master/internal/storage/user.go#L421. + +// Generate the hash of a password for storage in the database. +// NOTE: We store the details of the hashing algorithm with the hash itself, +// making it easy to recreate the hash for password checking, even if we change +// the default criteria here. +func (h pbkdf2Hasher) Hash(password string) (string, error) { + // Generate a random salt value with the given salt size. + salt := make([]byte, h.saltSize) + _, err := rand.Read(salt) + + // We need to ensure that salt doesn contain $, which is 36 in decimal. + // So we check if there'sbyte that represents $ and change it with a random number in the range 0-35 + //// This is far from ideal, but should be good enough with a reasonable salt size. + for i := 0; i < len(salt); i++ { + if salt[i] == 36 { + n, err := rand.Int(rand.Reader, big.NewInt(35)) + if err != nil { + return "", fmt.Errorf("read random byte error: %s", err) + } + + salt[i] = byte(n.Int64()) + break + } + } + if err != nil { + return "", fmt.Errorf("read random bytes error: %s", err) + } + + return h.hashWithSalt(password, salt, h.iterations, h.algorithm, h.keyLen), nil +} + +// HashCompare verifies that passed password hashes to the same value as the +// passed passwordHash. +// Reference: https://github.com/brocaar/chirpstack-application-server/blob/master/internal/storage/user.go#L458. +func (h pbkdf2Hasher) Compare(password string, passwordHash string) bool { + hashSplit := strings.Split(passwordHash, "$") + + if len(hashSplit) != 5 { + log.Errorf("invalid PBKDF2 hash supplied, expected length 5, got: %d", len(hashSplit)) + return false + } + + algorithm := hashSplit[1] + + iterations, err := strconv.Atoi(hashSplit[2]) + if err != nil { + log.Errorf("iterations error: %s", err) + return false + } + + var salt []byte + switch h.saltEncoding { + case UTF8: + salt = []byte(hashSplit[3]) + default: + salt, err = base64.StdEncoding.DecodeString(hashSplit[3]) + if err != nil { + log.Errorf("base64 salt error: %s", err) + return false + } + } + + hashedPassword, err := base64.StdEncoding.DecodeString(hashSplit[4]) + if err != nil { + log.Errorf("base64 hash decoding error: %s", err) + return false + } + + keylen := len(hashedPassword) + + return passwordHash == h.hashWithSalt(password, salt, iterations, algorithm, keylen) +} + +// Reference: https://github.com/brocaar/chirpstack-application-server/blob/master/internal/storage/user.go#L432. +func (h pbkdf2Hasher) hashWithSalt(password string, salt []byte, iterations int, algorithm string, keylen int) string { + // Generate the hashed password. This should be a little painful, adjust ITERATIONS + // if it needs performance tweeking. Greatly depends on the hardware. + // NOTE: We store these details with the returned hashed, so changes will not + // affect our ability to do password compares. + shaHash := sha512.New + if algorithm == SHA256 { + shaHash = sha256.New + } + + hashed := pbkdf2.Key([]byte(password), salt, iterations, keylen, shaHash) + + var buffer bytes.Buffer + + buffer.WriteString("PBKDF2$") + buffer.WriteString(fmt.Sprintf("%s$", algorithm)) + buffer.WriteString(strconv.Itoa(iterations)) + buffer.WriteString("$") + + switch h.saltEncoding { + case UTF8: + buffer.WriteString(string(salt)) + default: + buffer.WriteString(base64.StdEncoding.EncodeToString(salt)) + } + + buffer.WriteString("$") + buffer.WriteString(base64.StdEncoding.EncodeToString(hashed)) + + return buffer.String() +} diff --git a/pw-gen/pw.go b/pw-gen/pw.go index 63ca172..25bb69d 100644 --- a/pw-gen/pw.go +++ b/pw-gen/pw.go @@ -4,40 +4,53 @@ import ( "flag" "fmt" - "github.com/iegomez/mosquitto-go-auth/common" + "github.com/iegomez/mosquitto-go-auth/hashing" ) func main() { - const ( - sha256Size = 32 - sha512Size = 64 - ) - + var hasher = flag.String("h", "pbkdf2", "hasher: pbkdf2, argon2 or bcrypt") var algorithm = flag.String("a", "sha512", "algorithm: sha256 or sha512") - var HashIterations = flag.Int("i", 100000, "hash iterations") + var iterations = flag.Int("i", 100000, "hash iterations: defaults to 100000 for pbkdf2, please set to a reasonable value for argon2") var password = flag.String("p", "", "password") var saltSize = flag.Int("s", 16, "salt size") var saltEncoding = flag.String("e", "base64", "salt encoding") - var keylen = flag.Int("l", 0, "key length, recommend 32 for sha256 and 64 for sha512") + var keylen = flag.Int("l", 0, "key length, recommended values are 32 for sha256 and 64 for sha512") + var cost = flag.Int("c", 10, "bcrypt ost param") + var memory = flag.Int("m", 4096, "memory for argon2 hash") + var parallelism = flag.Int("pl", 2, "parallelism for argon2") flag.Parse() - // If supplied keylength is 0, use pre-defined key length shaSize := *keylen + if shaSize == 0 { switch *algorithm { - case "sha265": - shaSize = sha256Size - case "sha512": - shaSize = sha512Size + case hashing.SHA256: + shaSize = hashing.SHA256Size + case hashing.SHA512: + shaSize = hashing.SHA512Size default: - fmt.Println("Invalid password hash algorithm:", *algorithm) + fmt.Println("invalid password hash algorithm: ", *algorithm) return } } - pwHash, err := common.Hash(*password, *saltSize, *HashIterations, *algorithm, *saltEncoding, shaSize) + var hashComparer hashing.HashComparer + + switch *hasher { + case hashing.Argon2IDOpt: + hashComparer = hashing.NewArgon2IDHasher(*saltSize, *iterations, *keylen, uint32(*memory), uint8(*parallelism)) + case hashing.BcryptOpt: + hashComparer = hashing.NewBcryptHashComparer(*cost) + case hashing.Pbkdf2Opt: + hashComparer = hashing.NewPBKDF2Hasher(*saltSize, *iterations, *algorithm, *saltEncoding, *keylen) + default: + fmt.Println("invalid hasher option: ", *hasher) + return + } + + pwHash, err := hashComparer.Hash(*password) if err != nil { fmt.Printf("error: %s", err) } else {