mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 22:59:09 +02:00
move to argon2id
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package argon2id
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argon2Memory = 19456
|
||||
argon2Iterations = 2
|
||||
argon2Parallelism = 1
|
||||
argon2SaltLength = 16
|
||||
argon2KeyLength = 32
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidHash is returned when the hash string format is invalid
|
||||
ErrInvalidHash = errors.New("invalid hash format")
|
||||
|
||||
// ErrIncompatibleVersion is returned when the Argon2 version is not supported
|
||||
ErrIncompatibleVersion = errors.New("incompatible argon2 version")
|
||||
|
||||
// ErrMismatchedHashAndPassword is returned when password verification fails
|
||||
ErrMismatchedHashAndPassword = errors.New("password does not match hash")
|
||||
)
|
||||
|
||||
func Hash(secret string) (string, error) {
|
||||
salt := make([]byte, argon2SaltLength)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey(
|
||||
[]byte(secret),
|
||||
salt,
|
||||
argon2Iterations,
|
||||
argon2Memory,
|
||||
argon2Parallelism,
|
||||
argon2KeyLength,
|
||||
)
|
||||
|
||||
encodedSalt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
encodedHash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
argon2Memory,
|
||||
argon2Iterations,
|
||||
argon2Parallelism,
|
||||
encodedSalt,
|
||||
encodedHash,
|
||||
), nil
|
||||
}
|
||||
|
||||
func Verify(secret, encodedHash string) error {
|
||||
params, salt, hash, err := decodeHash(encodedHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
computedHash := argon2.IDKey(
|
||||
[]byte(secret),
|
||||
salt,
|
||||
params.iterations,
|
||||
params.memory,
|
||||
params.parallelism,
|
||||
params.keyLength,
|
||||
)
|
||||
|
||||
if subtle.ConstantTimeCompare(hash, computedHash) == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrMismatchedHashAndPassword
|
||||
}
|
||||
|
||||
type hashParams struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
parallelism uint8
|
||||
keyLength uint32
|
||||
version int
|
||||
}
|
||||
|
||||
func decodeHash(encodedHash string) (*hashParams, []byte, []byte, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
|
||||
if len(parts) != 6 {
|
||||
return nil, nil, nil, ErrInvalidHash
|
||||
}
|
||||
|
||||
if parts[1] != "argon2id" {
|
||||
return nil, nil, nil, ErrInvalidHash
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%w: invalid version: %v", ErrInvalidHash, err)
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return nil, nil, nil, ErrIncompatibleVersion
|
||||
}
|
||||
|
||||
var memory, iterations uint32
|
||||
var parallelism uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%w: invalid parameters: %v", ErrInvalidHash, err)
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%w: invalid salt encoding: %v", ErrInvalidHash, err)
|
||||
}
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%w: invalid hash encoding: %v", ErrInvalidHash, err)
|
||||
}
|
||||
|
||||
params := &hashParams{
|
||||
memory: memory,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
keyLength: uint32(len(hash)),
|
||||
version: version,
|
||||
}
|
||||
|
||||
return params, salt, hash, nil
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package argon2id
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
}{
|
||||
{
|
||||
name: "simple password",
|
||||
secret: "password123",
|
||||
},
|
||||
{
|
||||
name: "complex password with special chars",
|
||||
secret: "P@ssw0rd!#$%^&*()",
|
||||
},
|
||||
{
|
||||
name: "long password",
|
||||
secret: strings.Repeat("a", 100),
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
secret: "",
|
||||
},
|
||||
{
|
||||
name: "unicode password",
|
||||
secret: "пароль密码🔐",
|
||||
},
|
||||
{
|
||||
name: "numeric PIN",
|
||||
secret: "123456",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash, err := Hash(tt.secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify hash format
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf("Hash() = %v, want hash starting with $argon2id$", hash)
|
||||
}
|
||||
|
||||
// Verify hash has correct number of components
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 {
|
||||
t.Errorf("Hash() has %d parts, want 6", len(parts))
|
||||
}
|
||||
|
||||
// Verify version is present
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=") {
|
||||
t.Errorf("Hash() missing version, got %v", hash)
|
||||
}
|
||||
|
||||
// Verify each hash is unique (different salt)
|
||||
hash2, err := Hash(tt.secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() second call error = %v", err)
|
||||
}
|
||||
if hash == hash2 {
|
||||
t.Error("Hash() produces identical hashes for same input (salt not random)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
wantError error
|
||||
}{
|
||||
{
|
||||
name: "valid password",
|
||||
secret: "correctPassword",
|
||||
wantError: nil,
|
||||
},
|
||||
{
|
||||
name: "valid PIN",
|
||||
secret: "1234",
|
||||
wantError: nil,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
secret: "",
|
||||
wantError: nil,
|
||||
},
|
||||
{
|
||||
name: "unicode secret",
|
||||
secret: "密码🔐",
|
||||
wantError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Generate hash
|
||||
hash, err := Hash(tt.secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify correct secret
|
||||
err = Verify(tt.secret, hash)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("Verify() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIncorrectPassword(t *testing.T) {
|
||||
secret := "correctPassword"
|
||||
wrongSecret := "wrongPassword"
|
||||
|
||||
hash, err := Hash(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
err = Verify(wrongSecret, hash)
|
||||
if !errors.Is(err, ErrMismatchedHashAndPassword) {
|
||||
t.Errorf("Verify() error = %v, want %v", err, ErrMismatchedHashAndPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyInvalidHashFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
invalidHash string
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "empty hash",
|
||||
invalidHash: "",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "wrong algorithm",
|
||||
invalidHash: "$bcrypt$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "missing parts",
|
||||
invalidHash: "$argon2id$v=19$m=19456",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "too many parts",
|
||||
invalidHash: "$argon2id$v=19$m=19456,t=2,p=1$salt$hash$extra",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "invalid version format",
|
||||
invalidHash: "$argon2id$vXX$m=19456,t=2,p=1$c2FsdA$aGFzaA",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "invalid parameters format",
|
||||
invalidHash: "$argon2id$v=19$mXX,tYY,pZZ$c2FsdA$aGFzaA",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "invalid salt base64",
|
||||
invalidHash: "$argon2id$v=19$m=19456,t=2,p=1$not-valid-base64!@#$aGFzaA",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
{
|
||||
name: "invalid hash base64",
|
||||
invalidHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$not-valid-base64!@#",
|
||||
expectedError: ErrInvalidHash,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Verify("password", tt.invalidHash)
|
||||
if err == nil {
|
||||
t.Errorf("Verify() expected error, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, tt.expectedError) && !strings.Contains(err.Error(), tt.expectedError.Error()) {
|
||||
t.Errorf("Verify() error = %v, want error containing %v", err, tt.expectedError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIncompatibleVersion(t *testing.T) {
|
||||
// Manually craft a hash with wrong version
|
||||
invalidVersionHash := "$argon2id$v=18$m=19456,t=2,p=1$c2FsdDEyMzQ1Njc4OTA$aGFzaDEyMzQ1Njc4OTBhYmNkZWZnaGlqa2xtbm9w"
|
||||
|
||||
err := Verify("password", invalidVersionHash)
|
||||
if !errors.Is(err, ErrIncompatibleVersion) {
|
||||
t.Errorf("Verify() error = %v, want %v", err, ErrIncompatibleVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashDeterminism(t *testing.T) {
|
||||
// Ensure different hashes for same password (random salt)
|
||||
password := "testPassword"
|
||||
hashes := make(map[string]bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
hash, err := Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
if hashes[hash] {
|
||||
t.Error("Hash() produced duplicate hash (salt generation may be broken)")
|
||||
}
|
||||
hashes[hash] = true
|
||||
}
|
||||
|
||||
if len(hashes) != 10 {
|
||||
t.Errorf("Expected 10 unique hashes, got %d", len(hashes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOWASPCompliance(t *testing.T) {
|
||||
// Test that generated hashes use OWASP-recommended parameters
|
||||
secret := "testPassword"
|
||||
hash, err := Hash(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
params, _, _, err := decodeHash(hash)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeHash() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify OWASP minimum baseline parameters
|
||||
if params.memory != 19456 {
|
||||
t.Errorf("memory = %d, want 19456 (OWASP baseline)", params.memory)
|
||||
}
|
||||
if params.iterations != 2 {
|
||||
t.Errorf("iterations = %d, want 2 (OWASP baseline)", params.iterations)
|
||||
}
|
||||
if params.parallelism != 1 {
|
||||
t.Errorf("parallelism = %d, want 1 (OWASP baseline)", params.parallelism)
|
||||
}
|
||||
if params.keyLength != 32 {
|
||||
t.Errorf("keyLength = %d, want 32", params.keyLength)
|
||||
}
|
||||
if params.version != argon2.Version {
|
||||
t.Errorf("version = %d, want %d", params.version, argon2.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstantTimeComparison(t *testing.T) {
|
||||
// This test verifies that Verify() is using constant-time comparison
|
||||
// by ensuring it doesn't fail differently for similar vs different hashes
|
||||
secret := "password123"
|
||||
wrongSecret := "password124" // One character different
|
||||
|
||||
hash, err := Hash(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
// Both wrong passwords should return the same error
|
||||
err1 := Verify(wrongSecret, hash)
|
||||
err2 := Verify("completelydifferent", hash)
|
||||
|
||||
if !errors.Is(err1, ErrMismatchedHashAndPassword) {
|
||||
t.Errorf("Verify() error = %v, want %v", err1, ErrMismatchedHashAndPassword)
|
||||
}
|
||||
if !errors.Is(err2, ErrMismatchedHashAndPassword) {
|
||||
t.Errorf("Verify() error = %v, want %v", err2, ErrMismatchedHashAndPassword)
|
||||
}
|
||||
|
||||
// Errors should be identical (same error type and message)
|
||||
if err1.Error() != err2.Error() {
|
||||
t.Error("Verify() returns different errors for different wrong passwords (potential timing attack)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseSensitivity(t *testing.T) {
|
||||
// Passwords should be case-sensitive
|
||||
secret := "Password123"
|
||||
wrongSecret := "password123"
|
||||
|
||||
hash, err := Hash(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
// Correct password should verify
|
||||
if err := Verify(secret, hash); err != nil {
|
||||
t.Errorf("Verify() with correct password error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Wrong case should not verify
|
||||
if err := Verify(wrongSecret, hash); !errors.Is(err, ErrMismatchedHashAndPassword) {
|
||||
t.Errorf("Verify() with wrong case error = %v, want %v", err, ErrMismatchedHashAndPassword)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkHash(b *testing.B) {
|
||||
secret := "benchmarkPassword123"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Hash(secret)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkVerify(b *testing.B) {
|
||||
secret := "benchmarkPassword123"
|
||||
hash, _ := Hash(secret)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Verify(secret, hash)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user