feat(crypto): support for SHA2 and PHPass password hashes (#9809)

# Which Problems Are Solved

- Allow users to use SHA-256 and SHA-512 hashing algorithms. These
algorithms are used by Linux's crypt(3) function.
- Allow users to import passwords using the PHPass algorithm. This
algorithm is used by older PHP systems, WordPress in particular.

# How the Problems Are Solved

- Upgrade passwap to
[v0.9.0](https://github.com/zitadel/passwap/releases/tag/v0.9.0)
- Add sha2 and phpass as a new verifier option in defaults.yaml

# Additional Changes

- Updated docs to explain the two algorithms

# Additional Context
Implements the changes in the passwap library from
https://github.com/zitadel/passwap/pull/59 and
https://github.com/zitadel/passwap/pull/60

(cherry picked from commit 38013d0e84)
This commit is contained in:
Juriaan Kennedy
2025-05-16 17:53:45 +02:00
committed by Livio Spring
parent b19fd84760
commit 603799f409
4 changed files with 218 additions and 6 deletions

View File

@@ -14,7 +14,9 @@ import (
"github.com/zitadel/passwap/md5plain"
"github.com/zitadel/passwap/md5salted"
"github.com/zitadel/passwap/pbkdf2"
"github.com/zitadel/passwap/phpass"
"github.com/zitadel/passwap/scrypt"
"github.com/zitadel/passwap/sha2"
"github.com/zitadel/passwap/verifier"
"github.com/zitadel/zitadel/internal/zerrors"
@@ -51,6 +53,8 @@ const (
HashNameMd5 HashName = "md5" // verify only, as hashing with md5 is insecure and deprecated
HashNameMd5Plain HashName = "md5plain" // verify only, as hashing with md5 is insecure and deprecated
HashNameMd5Salted HashName = "md5salted" // verify only, as hashing with md5 is insecure and deprecated
HashNamePHPass HashName = "phpass" // verify only, as hashing with md5 is insecure and deprecated
HashNameSha2 HashName = "sha2" // hash and verify
HashNameScrypt HashName = "scrypt" // hash and verify
HashNamePBKDF2 HashName = "pbkdf2" // hash and verify
)
@@ -125,6 +129,14 @@ var knowVerifiers = map[HashName]prefixVerifier{
prefixes: []string{md5salted.Prefix},
verifier: md5salted.Verifier,
},
HashNameSha2: {
prefixes: []string{sha2.Sha256Identifier, sha2.Sha512Identifier},
verifier: sha2.Verifier,
},
HashNamePHPass: {
prefixes: []string{phpass.IdentifierP, phpass.IdentifierH},
verifier: phpass.Verifier,
},
}
func (c *HashConfig) buildVerifiers() (verifiers []verifier.Verifier, prefixes []string, err error) {
@@ -158,9 +170,11 @@ func (c *HasherConfig) buildHasher() (hasher passwap.Hasher, prefixes []string,
return c.scrypt()
case HashNamePBKDF2:
return c.pbkdf2()
case HashNameSha2:
return c.sha2()
case "":
return nil, nil, fmt.Errorf("missing hasher algorithm")
case HashNameArgon2, HashNameMd5:
case HashNameArgon2, HashNameMd5, HashNameMd5Plain, HashNameMd5Salted, HashNamePHPass:
fallthrough
default:
return nil, nil, fmt.Errorf("invalid algorithm %q", c.Algorithm)
@@ -296,6 +310,44 @@ func (c *HasherConfig) pbkdf2() (passwap.Hasher, []string, error) {
case HashModeSHA512:
return pbkdf2.NewSHA512(p), prefix, nil
default:
return nil, nil, fmt.Errorf("unsuppored pbkdf2 hash mode: %s", hash)
return nil, nil, fmt.Errorf("unsupported pbkdf2 hash mode: %s", hash)
}
}
func (c *HasherConfig) sha2Params() (use512 bool, rounds int, err error) {
var dst = struct {
Rounds uint32 `mapstructure:"Rounds"`
Hash HashMode `mapstructure:"Hash"`
}{}
if err := c.decodeParams(&dst); err != nil {
return false, 0, fmt.Errorf("decode sha2 params: %w", err)
}
switch dst.Hash {
case HashModeSHA256:
use512 = false
case HashModeSHA512:
use512 = true
case HashModeSHA1, HashModeSHA224, HashModeSHA384:
fallthrough
default:
return false, 0, fmt.Errorf("cannot use %s with sha2", dst.Hash)
}
if dst.Rounds > sha2.RoundsMax {
return false, 0, fmt.Errorf("rounds with sha2 cannot be larger than %d", sha2.RoundsMax)
} else {
rounds = int(dst.Rounds)
}
return use512, rounds, nil
}
func (c *HasherConfig) sha2() (passwap.Hasher, []string, error) {
use512, rounds, err := c.sha2Params()
if err != nil {
return nil, nil, err
}
if use512 {
return sha2.New512(rounds), []string{sha2.Sha256Identifier, sha2.Sha512Identifier}, nil
} else {
return sha2.New256(rounds), []string{sha2.Sha256Identifier, sha2.Sha512Identifier}, nil
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/zitadel/passwap/md5salted"
"github.com/zitadel/passwap/pbkdf2"
"github.com/zitadel/passwap/scrypt"
"github.com/zitadel/passwap/sha2"
)
func TestPasswordHasher_EncodingSupported(t *testing.T) {
@@ -78,7 +79,9 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) {
HashNameBcrypt,
HashNameMd5,
HashNameMd5Salted,
HashNamePHPass,
HashNameScrypt,
HashNameSha2,
"foobar",
},
Hasher: HasherConfig{
@@ -142,6 +145,15 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) {
},
wantErr: true,
},
{
name: "invalid phpass",
fields: fields{
Hasher: HasherConfig{
Algorithm: HashNamePHPass,
},
},
wantErr: true,
},
{
name: "invalid argon2",
fields: fields{
@@ -357,6 +369,59 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) {
},
wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix, md5salted.Prefix},
},
{
name: "sha2, parse error",
fields: fields{
Hasher: HasherConfig{
Algorithm: HashNameSha2,
Params: map[string]any{
"cost": "bar",
},
},
},
wantErr: true,
},
{
name: "pbkdf2, hash mode error",
fields: fields{
Hasher: HasherConfig{
Algorithm: HashNameSha2,
Params: map[string]any{
"Rounds": 12,
"Hash": "foo",
},
},
},
wantErr: true,
},
{
name: "sha2, sha256",
fields: fields{
Hasher: HasherConfig{
Algorithm: HashNameSha2,
Params: map[string]any{
"Rounds": 12,
"Hash": HashModeSHA256,
},
},
Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5, HashNameMd5Plain},
},
wantPrefixes: []string{sha2.Sha256Identifier, sha2.Sha512Identifier, argon2.Prefix, bcrypt.Prefix, md5.Prefix},
},
{
name: "sha2, sha512",
fields: fields{
Hasher: HasherConfig{
Algorithm: HashNameSha2,
Params: map[string]any{
"Rounds": 12,
"Hash": HashModeSHA512,
},
},
Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5, HashNameMd5Plain},
},
wantPrefixes: []string{sha2.Sha256Identifier, sha2.Sha512Identifier, argon2.Prefix, bcrypt.Prefix, md5.Prefix},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -723,3 +788,93 @@ func TestHasherConfig_pbkdf2Params(t *testing.T) {
})
}
}
func TestHasherConfig_sha2Params(t *testing.T) {
type fields struct {
Params map[string]any
}
tests := []struct {
name string
fields fields
want512 bool
wantRounds int
wantErr bool
}{
{
name: "decode error",
fields: fields{
Params: map[string]any{
"foo": "bar",
},
},
wantErr: true,
},
{
name: "sha1",
fields: fields{
Params: map[string]any{
"Rounds": 12,
"Hash": "sha1",
},
},
wantErr: true,
},
{
name: "sha224",
fields: fields{
Params: map[string]any{
"Rounds": 12,
"Hash": "sha224",
},
},
wantErr: true,
},
{
name: "sha256",
fields: fields{
Params: map[string]any{
"Rounds": 5000,
"Hash": "sha256",
},
},
want512: false,
wantRounds: 5000,
},
{
name: "sha384",
fields: fields{
Params: map[string]any{
"Rounds": 12,
"Hash": "sha384",
},
},
wantErr: true,
},
{
name: "sha512",
fields: fields{
Params: map[string]any{
"Rounds": 15000,
"Hash": "sha512",
},
},
want512: true,
wantRounds: 15000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &HasherConfig{
Params: tt.fields.Params,
}
got512, gotRounds, err := c.sha2Params()
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want512, got512)
assert.Equal(t, tt.wantRounds, gotRounds)
})
}
}