snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
91
server/channels/app/password/hashers/bcrypt.go
Normal file
91
server/channels/app/password/hashers/bcrypt.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/password/phcparser"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BCrypt implements the [PasswordHasher] interface using the
|
||||
// golang.org/x/crypto/bcrypt as the hashing method.
|
||||
//
|
||||
// This is the first hashing method used to hash passwords in the codebase, and so
|
||||
// it predates the implementation of the PHC string format for passwords. This means
|
||||
// that this hasher is *not* PHC-compliant, although its output kind of look like
|
||||
// PHC:
|
||||
//
|
||||
// $2a$10$z0OlN1MpiLVlLTyE1xtEjOJ6/xV95RAwwIUaYKQBAqoeyvPgLEnUa
|
||||
//
|
||||
// The format is $xy$n$salthash, where:
|
||||
// - $ is the literal '$' (1 byte)
|
||||
// - x is the major version (1 byte)
|
||||
// - y is the minor version (0 or 1 byte)
|
||||
// - $ is the literal '$' (1 byte)
|
||||
// - n is the cost (2 bytes)
|
||||
// - $ is the literal '$' (1 byte)
|
||||
// - salt is the encoded salt (22 bytes)
|
||||
// - hash is the encoded hash (31 bytes)
|
||||
//
|
||||
// In total, 60 bytes (59 if there is no minor version)
|
||||
//
|
||||
// But this is not PHC-compliant: xy is not the function id, n is not
|
||||
// a parameter name=value, nor the version, and there is no '$'
|
||||
// separating the salt and the hash.
|
||||
type BCrypt struct{}
|
||||
|
||||
const (
|
||||
// BCryptCost is the value of the cost parameter used throughout the history
|
||||
// of the codebase.
|
||||
BCryptCost = 10
|
||||
)
|
||||
|
||||
// NewBCrypt returns a new BCrypt instance
|
||||
func NewBCrypt() BCrypt {
|
||||
return BCrypt{}
|
||||
}
|
||||
|
||||
// Hash is a wrapper over golang.org/x/crypto/bcrypt.GenerateFromPassword, with
|
||||
// two main differences:
|
||||
// - If the password is too long, it returns [ErrPasswordTooLong] instead of
|
||||
// bcrypt.ErrPasswordTooLong, in order to comply with the rest of the hashers
|
||||
// in this package.
|
||||
// - It returns a string instead of a byte slice, so that [BCrypt] implements
|
||||
// the [PasswordHasher] interface.
|
||||
func (b BCrypt) Hash(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BCryptCost)
|
||||
if err != nil {
|
||||
if errors.Is(err, bcrypt.ErrPasswordTooLong) {
|
||||
return "", ErrPasswordTooLong
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// CompareHashAndPassword is a wrapper over
|
||||
// golang.org/x/crypto/bcrypt.CompareHashAndPassword, using the PHC's Hash field
|
||||
// as the input for its first argument: this is why [BCrypt] is an edge case for
|
||||
// a [PasswordHasher]: it only uses the [PHC.Hash] field, and ignores anything
|
||||
// else in there.
|
||||
func (b BCrypt) CompareHashAndPassword(hash phcparser.PHC, password string) error {
|
||||
if len(password) > PasswordMaxLengthBytes {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash.Hash), []byte(password))
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return ErrMismatchedHashAndPassword
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsPHCValid returns always false: [BCrypt] is not PHC compliant
|
||||
func (b BCrypt) IsPHCValid(hash phcparser.PHC) bool {
|
||||
return false
|
||||
}
|
||||
99
server/channels/app/password/hashers/bcrypt_test.go
Normal file
99
server/channels/app/password/hashers/bcrypt_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/password/phcparser"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBCryptHash(t *testing.T) {
|
||||
testCases := []struct {
|
||||
testName string
|
||||
pwd string
|
||||
expectedErr error
|
||||
}{
|
||||
// BCrypt.Hash is a very thing wrapper over crypto/bcrypt, so we only test
|
||||
// the differences with that method
|
||||
{
|
||||
testName: "valid password",
|
||||
pwd: "^a v3ery c0mp_ex Passw∙rd$",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
testName: "very long password",
|
||||
pwd: strings.Repeat("verylong", 72),
|
||||
expectedErr: ErrPasswordTooLong,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
hasher := NewBCrypt()
|
||||
|
||||
_, err := hasher.Hash(tc.pwd)
|
||||
if tc.expectedErr != nil {
|
||||
require.ErrorIs(t, err, tc.expectedErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBCryptCompareHashAndPassword(t *testing.T) {
|
||||
passwordTooLong := make([]byte, PasswordMaxLengthBytes+1)
|
||||
_, err := rand.Read(passwordTooLong)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
testName string
|
||||
storedPwd string
|
||||
inputPwd string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
"empty password",
|
||||
"",
|
||||
"",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"same password",
|
||||
"one password",
|
||||
"one password",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"different password",
|
||||
"one password",
|
||||
"another password",
|
||||
ErrMismatchedHashAndPassword,
|
||||
},
|
||||
{
|
||||
"long password",
|
||||
"stored password",
|
||||
string(passwordTooLong),
|
||||
ErrPasswordTooLong,
|
||||
},
|
||||
}
|
||||
|
||||
hasher := NewBCrypt()
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
hash, err := hasher.Hash(tc.storedPwd)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = hasher.CompareHashAndPassword(phcparser.PHC{Hash: hash}, tc.inputPwd)
|
||||
if tc.expectedErr != nil {
|
||||
require.ErrorIs(t, err, tc.expectedErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
12
server/channels/app/password/hashers/fips.go
Normal file
12
server/channels/app/password/hashers/fips.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:build requirefips
|
||||
|
||||
package hashers
|
||||
|
||||
import "github.com/mattermost/mattermost/server/public/model"
|
||||
|
||||
// fipsMinKeyLength is the minimum PBKDF2 key length enforced by the OpenSSL 3.x
|
||||
// FIPS provider (NIST SP 800-132: PBKDF password must be at least 14 bytes).
|
||||
const fipsMinKeyLength = model.PasswordFIPSMinimumLength
|
||||
9
server/channels/app/password/hashers/fips_default.go
Normal file
9
server/channels/app/password/hashers/fips_default.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:build !requirefips
|
||||
|
||||
package hashers
|
||||
|
||||
// fipsMinKeyLength is 0 in non-FIPS builds, meaning no minimum is enforced.
|
||||
const fipsMinKeyLength = 0
|
||||
152
server/channels/app/password/hashers/hashers.go
Normal file
152
server/channels/app/password/hashers/hashers.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Package hashers provides several implementations of password hashing functions.
|
||||
//
|
||||
// This package allows for seamless migrations of password hashing methods. To
|
||||
// migrate to a new hasher in the future, the steps needed are:
|
||||
// 1. Add a new type that implements the [PasswordHasher] interface. Let's call
|
||||
// it `NewHasher`.
|
||||
// 2. Update the [latestHasher] variable so that it points to a `NewHasher`
|
||||
// instance:
|
||||
//
|
||||
// ``` diff
|
||||
// var (
|
||||
//
|
||||
// // latestHasher is the hasher currently in use.
|
||||
// // Any password hashed with a different hasher must be migrated to this one.
|
||||
//
|
||||
// - latestHasher PasswordHasher = DefaultPBKDF2()
|
||||
// + latestHasher PasswordHasher = DefaultNewHasher()
|
||||
// ```
|
||||
// 3. Modify [GetHasherFromPHCString] to add a new case in the switch to
|
||||
// identify the new function ID.
|
||||
//
|
||||
// If what is needed is to upgrade to a new set of parameters for the same
|
||||
// hashing method (let's say keep using PBKDF2 but increase the work factor
|
||||
// from 60,000 to 120,000), then no modification to [GetHasherFromPHCString]
|
||||
// is needed. Simply update the [latestHasher] varible with the new parameter,
|
||||
// and [IsPHCValid] will detect the difference in the parameter.
|
||||
//
|
||||
// Note that the migration happens in [App.migratePassword], which is triggered
|
||||
// whenever the user enters their password and an old hashing method is
|
||||
// identified when parsing their stored hashed password.
|
||||
// This means that the older password hashers can *never* be removed, unless all
|
||||
// users whose passwords are not migrated are either forced to re-login, or
|
||||
// forced to generate a new password.
|
||||
//
|
||||
// Another important note is that once a server upgrades to a version with an
|
||||
// updated hashing method, downgrading to any previous version will break the
|
||||
// login flow for users whose passwords were migrated to the newer method.
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/password/phcparser"
|
||||
)
|
||||
|
||||
// PasswordHasher is a password hasher compliant with the PHC string format:
|
||||
// https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md
|
||||
//
|
||||
// Implementations of this interface need to make sure that all of the methods
|
||||
// are thread-safe.
|
||||
type PasswordHasher interface {
|
||||
// Hash computes a hash of the provided password, returning a PHC string
|
||||
// containing all the information that was needed to compute it: the function
|
||||
// used to generate it, its version, parameters and salt, if needed.
|
||||
//
|
||||
// If an implementation of Hash needs a salt to generate the hash, it will
|
||||
// create one, so that callers don't need to provide it.
|
||||
Hash(password string) (string, error)
|
||||
|
||||
// CompareHashAndPassword compares the parsed PHC and a provided password,
|
||||
// returning an error if and only if the hashes do not match.
|
||||
//
|
||||
// Implementations need to make sure that the comparisons are done in constant
|
||||
// time; e.g., using crypto/internal/fips140/subtle.ConstantTimeCompare.
|
||||
CompareHashAndPassword(hash phcparser.PHC, password string) error
|
||||
|
||||
// IsPHCValid validates whether a parsed PHC string conforms to the parameters
|
||||
// of the hasher.
|
||||
IsPHCValid(hash phcparser.PHC) bool
|
||||
}
|
||||
|
||||
const (
|
||||
// Maximum password length for all password hashers.
|
||||
PasswordMaxLengthBytes = model.UserPasswordMaxLength
|
||||
)
|
||||
|
||||
var (
|
||||
// latestHasher is the hasher currently in use.
|
||||
// Any password hashed with a different hasher must be migrated to this one.
|
||||
latestHasher PasswordHasher = DefaultPBKDF2()
|
||||
|
||||
// ErrPasswordTooLong wraps [model.ErrPasswordTooLong] so that errors.Is
|
||||
// matches both the hashers-level and model-level sentinel.
|
||||
ErrPasswordTooLong = fmt.Errorf("hashers: %w", model.ErrPasswordTooLong)
|
||||
|
||||
// ErrMismatchedHashAndPassword is the error returned when the provided
|
||||
// password does not match the stored hash
|
||||
ErrMismatchedHashAndPassword = fmt.Errorf("hash and password do not match")
|
||||
)
|
||||
|
||||
// getOriginalHasher returns a [BCrypt] hasher, the first hasher used in the
|
||||
// codebase.
|
||||
func getOriginalHasher(phcString string) (PasswordHasher, phcparser.PHC) {
|
||||
// [BCrypt] is somewhat of an edge case, since it is not PHC-compliant, and
|
||||
// needs the whole PHC string in its Hash field
|
||||
return NewBCrypt(), phcparser.PHC{Hash: phcString}
|
||||
}
|
||||
|
||||
// GetHasherFromPHC returns the password hasher that was used to generate the
|
||||
// provided PHC string.
|
||||
// If the PHC string is not valid, or the function ID is unknown, this function
|
||||
// defaults to the first hasher ever used, which did not properly implement PHC:
|
||||
// the [BCrypt] hasher.
|
||||
func GetHasherFromPHCString(phcString string) (PasswordHasher, phcparser.PHC, error) {
|
||||
phc, err := phcparser.New(strings.NewReader(phcString)).Parse()
|
||||
if err != nil {
|
||||
// If the PHC string is invalid, return the original hasher, bcrypt
|
||||
bcrypt, bcryptPhc := getOriginalHasher(phcString)
|
||||
return bcrypt, bcryptPhc, nil
|
||||
}
|
||||
|
||||
// First check whether PHC conforms to the latest hasher
|
||||
if latestHasher.IsPHCValid(phc) {
|
||||
return latestHasher, phc, nil
|
||||
}
|
||||
|
||||
// If not, check the function ID and create a new one depending on it
|
||||
switch phc.Id {
|
||||
case PBKDF2FunctionId:
|
||||
pbkdf2, err := NewPBKDF2FromPHC(phc)
|
||||
if err != nil {
|
||||
return PBKDF2{}, phcparser.PHC{}, fmt.Errorf("the provided PHC string is PBKDF2, but is not valid: %w", err)
|
||||
}
|
||||
return pbkdf2, phc, nil
|
||||
// If the function ID is unknown, return the original hasher
|
||||
default:
|
||||
bcrypt, phc := getOriginalHasher(phcString)
|
||||
return bcrypt, phc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Hash hashes the provided password with the latest hashing method.
|
||||
func Hash(password string) (string, error) {
|
||||
return GetLatestHasher().Hash(password)
|
||||
}
|
||||
|
||||
// CompareHashAndPassword compares the parsed [phcparser.PHC] and the provided
|
||||
// password using the latest hashing method.
|
||||
func CompareHashAndPassword(phc phcparser.PHC, password string) error {
|
||||
return GetLatestHasher().CompareHashAndPassword(phc, password)
|
||||
}
|
||||
|
||||
// IsLatestHasher verifies that the provided hasher is the latest one. This
|
||||
// function is useful for identifying stored hashes that require a migration.
|
||||
func IsLatestHasher(hasher PasswordHasher) bool {
|
||||
return GetLatestHasher() == hasher
|
||||
}
|
||||
62
server/channels/app/password/hashers/hashers_dev.go
Normal file
62
server/channels/app/password/hashers/hashers_dev.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:build !production
|
||||
|
||||
package hashers
|
||||
|
||||
import "testing"
|
||||
|
||||
// testHasher is used during tests to override the latestHasher with a faster
|
||||
// alternative. This should only be set via SetTestHasher and only in test code.
|
||||
var testHasher PasswordHasher
|
||||
|
||||
// GetLatestHasher returns the hasher to use for password operations.
|
||||
// In non-production builds, if a test hasher has been set via SetTestHasher,
|
||||
// it will be returned instead of the production latestHasher.
|
||||
func GetLatestHasher() PasswordHasher {
|
||||
if testHasher != nil {
|
||||
return testHasher
|
||||
}
|
||||
return latestHasher
|
||||
}
|
||||
|
||||
// SetTestHasher sets a hasher to be used instead of the latestHasher during tests.
|
||||
// This is useful for speeding up tests that create many users, as password hashing
|
||||
// is computationally expensive. Pass nil to restore normal behavior.
|
||||
//
|
||||
// This function is only available in non-production builds and should only be
|
||||
// called from test code, typically in TestMain. It will panic if called outside
|
||||
// of a test context.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// func TestMain(m *testing.M) {
|
||||
// hashers.SetTestHasher(hashers.FastTestHasher())
|
||||
// os.Exit(m.Run())
|
||||
// }
|
||||
func SetTestHasher(h PasswordHasher) {
|
||||
if !testing.Testing() {
|
||||
panic("SetTestHasher called outside of test context")
|
||||
}
|
||||
testHasher = h
|
||||
}
|
||||
|
||||
// fastTestHasherWorkFactor is the minimum iteration count that the OpenSSL 3.x
|
||||
// FIPS provider accepts for PBKDF2 (NIST SP 800-132 floor). Using anything
|
||||
// lower causes PKCS5_PBKDF2_HMAC to fail with "invalid iteration count" in
|
||||
// FIPS mode. This is still ~600x faster than the production default (600000).
|
||||
const fastTestHasherWorkFactor = 1000
|
||||
|
||||
// FastTestHasher returns a PBKDF2 hasher configured with minimal work factor
|
||||
// for use in tests while still producing valid password hashes that can be
|
||||
// verified.
|
||||
//
|
||||
// This function is only available in non-production builds.
|
||||
func FastTestHasher() PasswordHasher {
|
||||
h, err := NewPBKDF2(fastTestHasherWorkFactor, defaultKeyLength)
|
||||
if err != nil {
|
||||
panic("failed to create fast test hasher: " + err.Error())
|
||||
}
|
||||
return h
|
||||
}
|
||||
87
server/channels/app/password/hashers/hashers_dev_test.go
Normal file
87
server/channels/app/password/hashers/hashers_dev_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:build !production
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetTestHasher(t *testing.T) {
|
||||
// Ensure testHasher starts as nil
|
||||
SetTestHasher(nil)
|
||||
|
||||
// Hash should work with nil testHasher (uses latestHasher)
|
||||
hash1, err := Hash("T3stP@ssw0rd!xYz")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hash1)
|
||||
|
||||
// Set a fast test hasher
|
||||
fastHasher := FastTestHasher()
|
||||
SetTestHasher(fastHasher)
|
||||
defer SetTestHasher(nil)
|
||||
|
||||
// Hash should now use the fast test hasher
|
||||
hash2, err := Hash("T3stP@ssw0rd!xYz")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hash2)
|
||||
|
||||
// Verify the hash was generated with different parameters
|
||||
// The fast hasher uses the FIPS-minimum work factor.
|
||||
require.Contains(t, hash2, ",w=1000,")
|
||||
|
||||
// Verify the password can be verified against the hash
|
||||
hasher, phc, err := GetHasherFromPHCString(hash2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, hasher.CompareHashAndPassword(phc, "T3stP@ssw0rd!xYz"))
|
||||
require.Error(t, hasher.CompareHashAndPassword(phc, "Wr0ngP@ssw0rd!!"))
|
||||
}
|
||||
|
||||
func TestFastTestHasher(t *testing.T) {
|
||||
hasher := FastTestHasher()
|
||||
require.NotNil(t, hasher)
|
||||
|
||||
// Verify it's a PBKDF2 hasher with the FIPS-minimum work factor.
|
||||
pbkdf2Hasher, ok := hasher.(PBKDF2)
|
||||
require.True(t, ok, "FastTestHasher should return a PBKDF2 hasher")
|
||||
require.Equal(t, fastTestHasherWorkFactor, pbkdf2Hasher.workFactor)
|
||||
|
||||
// Test that it produces valid hashes
|
||||
testPassword := "T3stP@ssw0rd!xYz"
|
||||
hash, err := hasher.Hash(testPassword)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, hash, ",w=1000,")
|
||||
|
||||
// Verify the hash can be validated
|
||||
parsedHasher, phc, err := GetHasherFromPHCString(hash)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, parsedHasher.CompareHashAndPassword(phc, testPassword))
|
||||
}
|
||||
|
||||
func TestGetLatestHasher(t *testing.T) {
|
||||
// Ensure testHasher starts as nil
|
||||
SetTestHasher(nil)
|
||||
|
||||
// Without test hasher, should return latestHasher
|
||||
require.Equal(t, latestHasher, GetLatestHasher())
|
||||
|
||||
// Set a fast test hasher
|
||||
fastHasher := FastTestHasher()
|
||||
SetTestHasher(fastHasher)
|
||||
defer SetTestHasher(nil)
|
||||
|
||||
// With test hasher set, should return the test hasher
|
||||
require.Equal(t, fastHasher, GetLatestHasher())
|
||||
require.NotEqual(t, latestHasher, GetLatestHasher())
|
||||
}
|
||||
|
||||
func BenchmarkFastTestHasher(b *testing.B) {
|
||||
hasher := FastTestHasher()
|
||||
for b.Loop() {
|
||||
_, _ = hasher.Hash("password")
|
||||
}
|
||||
}
|
||||
12
server/channels/app/password/hashers/hashers_production.go
Normal file
12
server/channels/app/password/hashers/hashers_production.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
//go:build production
|
||||
|
||||
package hashers
|
||||
|
||||
// GetLatestHasher returns the hasher to use for password operations.
|
||||
// In production builds, this always returns the latestHasher.
|
||||
func GetLatestHasher() PasswordHasher {
|
||||
return latestHasher
|
||||
}
|
||||
145
server/channels/app/password/hashers/hashers_test.go
Normal file
145
server/channels/app/password/hashers/hashers_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/password/phcparser"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetHasherFromPHCString(t *testing.T) {
|
||||
testCases := []struct {
|
||||
testName string
|
||||
input string
|
||||
expectedHasher PasswordHasher
|
||||
expectedPHC phcparser.PHC
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
testName: "latest hasher (PBKDF2)",
|
||||
input: "$pbkdf2$f=SHA256,w=600000,l=32$5Zq8TvET7nMrXof49Rp4Sw$d0Mx8467kv+3ylbGrkyu4jTd8O8SP51k4s1RuWb9S/o",
|
||||
expectedHasher: latestHasher,
|
||||
expectedPHC: phcparser.PHC{
|
||||
Id: "pbkdf2",
|
||||
Version: "",
|
||||
Params: map[string]string{
|
||||
"f": "SHA256",
|
||||
"w": "600000",
|
||||
"l": "32",
|
||||
},
|
||||
Salt: "5Zq8TvET7nMrXof49Rp4Sw",
|
||||
Hash: "d0Mx8467kv+3ylbGrkyu4jTd8O8SP51k4s1RuWb9S/o",
|
||||
},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
testName: "valid, non-default PBKDF2",
|
||||
input: "$pbkdf2$f=SHA256,w=10000,l=10$5Zq8TvET7nMrXof49Rp4Sw$d0Mx8467kv+3ylbGrkyu4jTd8O8SP51k4s1RuWb9S/o",
|
||||
expectedHasher: PBKDF2{
|
||||
workFactor: 10000,
|
||||
keyLength: 10,
|
||||
phcHeader: "$pbkdf2$f=SHA256,w=10000,l=10$",
|
||||
},
|
||||
expectedPHC: phcparser.PHC{
|
||||
Id: "pbkdf2",
|
||||
Version: "",
|
||||
Params: map[string]string{
|
||||
"f": "SHA256",
|
||||
"w": "10000",
|
||||
"l": "10",
|
||||
},
|
||||
Salt: "5Zq8TvET7nMrXof49Rp4Sw",
|
||||
Hash: "d0Mx8467kv+3ylbGrkyu4jTd8O8SP51k4s1RuWb9S/o",
|
||||
},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
testName: "valid bcrypt",
|
||||
input: "$2a$10$z0OlN1MpiLVlLTyE1xtEjOJ6/xV95RAwwIUaYKQBAqoeyvPgLEnUa",
|
||||
expectedHasher: NewBCrypt(),
|
||||
expectedPHC: phcparser.PHC{
|
||||
Hash: "$2a$10$z0OlN1MpiLVlLTyE1xtEjOJ6/xV95RAwwIUaYKQBAqoeyvPgLEnUa",
|
||||
},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
testName: "invalid phc - default to bcrypt",
|
||||
input: "invalid",
|
||||
expectedHasher: NewBCrypt(),
|
||||
expectedPHC: phcparser.PHC{
|
||||
Hash: "invalid",
|
||||
},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
testName: "valid PBKDF2 with invalid parameters",
|
||||
input: "$pbkdf2$f=SHA256,w=-50,l=0$5Zq8TvET7nMrXof49Rp4Sw$d0Mx8467kv+3ylbGrkyu4jTd8O8SP51k4s1RuWb9S/o",
|
||||
expectedHasher: PBKDF2{
|
||||
workFactor: 10000,
|
||||
keyLength: 10,
|
||||
phcHeader: "$pbkdf2$f=SHA256,w=10000,l=10$",
|
||||
},
|
||||
expectedPHC: phcparser.PHC{},
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
actualHasher, actualPHC, err := GetHasherFromPHCString(tc.input)
|
||||
if tc.expectedErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedHasher, actualHasher)
|
||||
require.Equal(t, tc.expectedPHC, actualPHC)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLatestHasher(t *testing.T) {
|
||||
pbkdf2WithOtherParams, err := NewPBKDF2(10000, 16)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
testName string
|
||||
inputHasher PasswordHasher
|
||||
expectedOutput bool
|
||||
}{
|
||||
{
|
||||
"latestHasher is the latest hasher",
|
||||
latestHasher,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"DefaultPBKDF2 is the latest hasher",
|
||||
DefaultPBKDF2(),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"PBKDF2 with other parameters is not the latest hasher",
|
||||
pbkdf2WithOtherParams,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bcrypt is not the latest hasher",
|
||||
NewBCrypt(),
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
actualOutput := IsLatestHasher(tc.inputHasher)
|
||||
require.Equal(t, tc.expectedOutput, actualOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDefaultHasher(b *testing.B) {
|
||||
hasher := DefaultPBKDF2()
|
||||
for b.Loop() {
|
||||
_, _ = hasher.Hash("password")
|
||||
}
|
||||
}
|
||||
244
server/channels/app/password/hashers/pbkdf2.go
Normal file
244
server/channels/app/password/hashers/pbkdf2.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/password/phcparser"
|
||||
)
|
||||
|
||||
const (
|
||||
// PBKDF2FunctionId is the name of the PBKDF2 hasher.
|
||||
PBKDF2FunctionId string = "pbkdf2"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default parameter values
|
||||
defaultPRFName = "SHA256"
|
||||
defaultWorkFactor = 600000
|
||||
defaultKeyLength = 32
|
||||
|
||||
// Length of the salt, in bytes
|
||||
saltLenBytes = 16
|
||||
)
|
||||
|
||||
var (
|
||||
defaultPRF = sha256.New
|
||||
)
|
||||
|
||||
// PBKDF2 implements the [PasswordHasher] interface using [crypto/pbkdf2] as the
|
||||
// hashing method.
|
||||
//
|
||||
// It is parametrized by:
|
||||
// - The work factor: the number of iterations performed during hashing. The
|
||||
// larger this number, the longer and more costly the hashing process. OWASP
|
||||
// has some recommendations on what number to use here:
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
|
||||
// - The key length: the desired length, in bytes, of the resulting hash.
|
||||
//
|
||||
// The internal hashing function is always set to SHA256.
|
||||
//
|
||||
// Its PHC string is of the form:
|
||||
//
|
||||
// $pbkdf2$f=<F>,w=<W>,l=<L>$<salt>$<hash>
|
||||
//
|
||||
// Where:
|
||||
// - <F> is a string specifying the internal hashing function (defaults to SHA256).
|
||||
// - <W> is an integer specifying the work factor (defaults to 600000).
|
||||
// - <L> is an integer specifying the key length (defaults to 32).
|
||||
// - <salt> is the base64-encoded salt.
|
||||
// - <hash> is the base64-encoded hash.
|
||||
type PBKDF2 struct {
|
||||
workFactor int
|
||||
keyLength int
|
||||
|
||||
phcHeader string
|
||||
}
|
||||
|
||||
// DefaultPBKDF2 returns a [PBKDF2] already initialized with the following
|
||||
// parameters:
|
||||
// - Internal hashing function: SHA256
|
||||
// - Work factor: 600,000
|
||||
// - Key length: 32 bytes
|
||||
func DefaultPBKDF2() PBKDF2 {
|
||||
hasher, err := NewPBKDF2(defaultWorkFactor, defaultKeyLength)
|
||||
if err != nil {
|
||||
panic("DefaultPBKDF2 implementation is incorrect")
|
||||
}
|
||||
return hasher
|
||||
}
|
||||
|
||||
// NewPBKDF2 returns a [PBKDF2] initialized with the provided parameters
|
||||
func NewPBKDF2(workFactor int, keyLength int) (PBKDF2, error) {
|
||||
if workFactor <= 0 {
|
||||
return PBKDF2{}, fmt.Errorf("work factor must be strictly positive")
|
||||
}
|
||||
|
||||
if keyLength <= 0 {
|
||||
return PBKDF2{}, fmt.Errorf("key length must be strictly positive")
|
||||
}
|
||||
// Precompute and store the PHC header, since it is common to every hashed
|
||||
// password; it will be something like:
|
||||
// $pbkdf2$f=SHA256,w=600000,l=32$
|
||||
phcHeader := new(strings.Builder)
|
||||
|
||||
// First, the function ID
|
||||
phcHeader.WriteRune('$')
|
||||
phcHeader.WriteString(PBKDF2FunctionId)
|
||||
|
||||
// Then, the parameters
|
||||
phcHeader.WriteString("$f=")
|
||||
phcHeader.WriteString(defaultPRFName)
|
||||
phcHeader.WriteString(",w=")
|
||||
phcHeader.WriteString(strconv.Itoa(workFactor))
|
||||
phcHeader.WriteString(",l=")
|
||||
phcHeader.WriteString(strconv.Itoa(keyLength))
|
||||
|
||||
// Finish with the '$' that will mark the start of the salt
|
||||
phcHeader.WriteRune('$')
|
||||
|
||||
return PBKDF2{
|
||||
workFactor: workFactor,
|
||||
keyLength: keyLength,
|
||||
phcHeader: phcHeader.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewPBKDF2FromPHC returns a [PBKDF2] that conforms to the provided parsed PHC,
|
||||
// using the same parameters (if valid) present there.
|
||||
func NewPBKDF2FromPHC(phc phcparser.PHC) (PBKDF2, error) {
|
||||
workFactor, err := strconv.Atoi(phc.Params["w"])
|
||||
if err != nil {
|
||||
return PBKDF2{}, fmt.Errorf("invalid work factor parameter 'w=%s'", phc.Params["w"])
|
||||
}
|
||||
|
||||
keyLength, err := strconv.Atoi(phc.Params["l"])
|
||||
if err != nil {
|
||||
return PBKDF2{}, fmt.Errorf("invalid key length parameter 'l=%s'", phc.Params["l"])
|
||||
}
|
||||
|
||||
return NewPBKDF2(workFactor, keyLength)
|
||||
}
|
||||
|
||||
// hashWithSalt calls crypto/pbkdf2.Key with the provided salt and the stored
|
||||
// parameters.
|
||||
func (p PBKDF2) hashWithSalt(password string, salt []byte) (string, error) {
|
||||
hash, err := pbkdf2.Key(defaultPRF, password, salt, p.workFactor, p.keyLength)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed hashing the password: %w", err)
|
||||
}
|
||||
|
||||
encodedHash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
return encodedHash, nil
|
||||
}
|
||||
|
||||
// Hash hashes the provided password using the PBKDF2 algorithm with the stored
|
||||
// parameters, returning a PHC-compliant string.
|
||||
//
|
||||
// The salt is generated randomly and stored in the returned PHC string. If the
|
||||
// provided password is longer than [PasswordMaxLengthBytes], [ErrPasswordTooLong]
|
||||
// is returned.
|
||||
func (p PBKDF2) Hash(password string) (string, error) {
|
||||
// Enforce a maximum length, even if PBKDF2 can theoretically accept *any* length
|
||||
if len(password) > PasswordMaxLengthBytes {
|
||||
return "", ErrPasswordTooLong
|
||||
}
|
||||
|
||||
// Create random salt
|
||||
salt := make([]byte, saltLenBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return "", fmt.Errorf("unable to generate salt for user: %w", err)
|
||||
}
|
||||
|
||||
// Compute hash
|
||||
hash, err := p.hashWithSalt(password, salt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash the password: %w", err)
|
||||
}
|
||||
|
||||
// Initialize string builder and base64 encoder
|
||||
phcString := new(strings.Builder)
|
||||
b64Encoder := base64.RawStdEncoding
|
||||
|
||||
// Now, start writing: first, the stored header: function ID and parameters
|
||||
phcString.WriteString(p.phcHeader)
|
||||
|
||||
// Next, the encoded salt (the header already contains the initial $, so we
|
||||
// can skip it)
|
||||
// If we were to use a real encoder using an io.Writer, we would need to
|
||||
// call Close after the salt, otherwise the last block doesn't get written;
|
||||
// but we don't want to close it yet, because we want to write the hash later;
|
||||
// so I think it's not worth using an encoder, and it's better to call
|
||||
// EncodeToString directly, here and when writing the hash
|
||||
phcString.WriteString(b64Encoder.EncodeToString(salt))
|
||||
|
||||
// Finally, the encoded hash
|
||||
phcString.WriteRune('$')
|
||||
phcString.WriteString(hash)
|
||||
|
||||
return phcString.String(), nil
|
||||
}
|
||||
|
||||
// CompareHashAndPassword compares the provided [phcparser.PHC] with the plain-text
|
||||
// password.
|
||||
//
|
||||
// The provided [phcparser.PHC] is validated to double-check it was generated with
|
||||
// this hasher and parameters.
|
||||
func (p PBKDF2) CompareHashAndPassword(hash phcparser.PHC, password string) error {
|
||||
if len(password) > PasswordMaxLengthBytes {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
|
||||
// Under FIPS, the OpenSSL PBKDF2 implementation requires keys of at least
|
||||
// fipsMinKeyLength bytes. A password shorter than this can never match a
|
||||
// stored hash, so return ErrMismatchedHashAndPassword directly rather than
|
||||
// letting PBKDF2 fail with a generic crypto error.
|
||||
if fipsMinKeyLength > 0 && len(password) < fipsMinKeyLength {
|
||||
return ErrMismatchedHashAndPassword
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if !p.IsPHCValid(hash) {
|
||||
return fmt.Errorf("the stored password does not comply with the PBKDF2 parser's PHC serialization")
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(hash.Salt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed decoding hash's salt: %w", err)
|
||||
}
|
||||
|
||||
// Hash the new password with the stored hash's salt
|
||||
newHash, err := p.hashWithSalt(password, salt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash the password: %w", err)
|
||||
}
|
||||
|
||||
// Compare both hashes
|
||||
if subtle.ConstantTimeCompare([]byte(hash.Hash), []byte(newHash)) != 1 {
|
||||
return ErrMismatchedHashAndPassword
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPHCValid validates that the provided [phcparser.PHC] is valid, meaning:
|
||||
// - The function used to generate it was [PBKDF2FunctionId].
|
||||
// - The parameters used to generate it were the same as the ones used to
|
||||
// create this hasher.
|
||||
func (p PBKDF2) IsPHCValid(phc phcparser.PHC) bool {
|
||||
return phc.Id == PBKDF2FunctionId &&
|
||||
len(phc.Params) == 3 &&
|
||||
phc.Params["f"] == defaultPRFName &&
|
||||
phc.Params["w"] == strconv.Itoa(p.workFactor) &&
|
||||
phc.Params["l"] == strconv.Itoa(p.keyLength)
|
||||
}
|
||||
110
server/channels/app/password/hashers/pbkdf2_test.go
Normal file
110
server/channels/app/password/hashers/pbkdf2_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/password/phcparser"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPBKDF2Hash(t *testing.T) {
|
||||
password := "^a v3ery c0mp_ex Passw∙rd$"
|
||||
workFactor := 600000
|
||||
keyLength := 32
|
||||
|
||||
hasher, err := NewPBKDF2(workFactor, keyLength)
|
||||
require.NoError(t, err)
|
||||
|
||||
str, err := hasher.Hash(password)
|
||||
require.NoError(t, err)
|
||||
|
||||
phc, err := phcparser.New(strings.NewReader(str)).Parse()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "pbkdf2", phc.Id)
|
||||
require.Equal(t, "", phc.Version)
|
||||
require.Equal(t, map[string]string{
|
||||
"f": "SHA256",
|
||||
"w": "600000",
|
||||
"l": "32",
|
||||
}, phc.Params)
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(phc.Salt)
|
||||
require.NoError(t, err)
|
||||
|
||||
hash, err := pbkdf2.Key(sha256.New, password, salt, workFactor, keyLength)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedHash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
require.Equal(t, expectedHash, phc.Hash)
|
||||
}
|
||||
|
||||
func TestPBKDF2CompareHashAndPassword(t *testing.T) {
|
||||
passwordTooLong := make([]byte, PasswordMaxLengthBytes+1)
|
||||
_, err := rand.Read(passwordTooLong)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
testName string
|
||||
storedPwd string
|
||||
inputPwd string
|
||||
expectedErr error
|
||||
skipFIPS bool
|
||||
}{
|
||||
{
|
||||
testName: "empty password",
|
||||
storedPwd: "",
|
||||
inputPwd: "",
|
||||
expectedErr: nil,
|
||||
skipFIPS: true,
|
||||
},
|
||||
{
|
||||
testName: "same password",
|
||||
storedPwd: "one password!!!",
|
||||
inputPwd: "one password!!!",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
testName: "different password",
|
||||
storedPwd: "one password!!!",
|
||||
inputPwd: "another password",
|
||||
expectedErr: ErrMismatchedHashAndPassword,
|
||||
},
|
||||
{
|
||||
testName: "password too long",
|
||||
storedPwd: "stored password",
|
||||
inputPwd: string(passwordTooLong),
|
||||
expectedErr: ErrPasswordTooLong,
|
||||
},
|
||||
}
|
||||
|
||||
hasher := DefaultPBKDF2()
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
if tc.skipFIPS && model.FIPSEnabled {
|
||||
t.Skip("skipping under FIPS: PBKDF2 requires keys >= 14 bytes")
|
||||
}
|
||||
storedPHCStr, err := hasher.Hash(tc.storedPwd)
|
||||
require.NoError(t, err)
|
||||
|
||||
storedPHC, err := phcparser.New(strings.NewReader(storedPHCStr)).Parse()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = hasher.CompareHashAndPassword(storedPHC, tc.inputPwd)
|
||||
if tc.expectedErr != nil {
|
||||
require.ErrorIs(t, err, tc.expectedErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user