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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View 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
}

View 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)
}
})
}
}

View 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

View 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

View 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
}

View 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
}

View 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")
}
}

View 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
}

View 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")
}
}

View 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)
}

View 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)
}
})
}
}