refactor: use pinned golangci-lint Docker image for linting
All checks were successful
check / check (push) Successful in 1m37s
All checks were successful
check / check (push) Successful in 1m37s
Refactor Dockerfile to use a separate lint stage with a pinned golangci-lint v2.11.3 Docker image instead of installing golangci-lint via curl in the builder stage. This follows the pattern used by sneak/pixa. Changes: - Dockerfile: separate lint stage using golangci/golangci-lint:v2.11.3 (Debian-based, pinned by sha256) with COPY --from=lint dependency - Bump Go from 1.24 to 1.26.1 (golang:1.26.1-bookworm, pinned) - Bump golangci-lint from v1.64.8 to v2.11.3 - Migrate .golangci.yml from v1 to v2 format (same linters, format only) - All Docker images pinned by sha256 digest - Fix all lint issues from the v2 linter upgrade: - Add package comments to all packages - Add doc comments to all exported types, functions, and methods - Fix unchecked errors (errcheck) - Fix unused parameters (revive) - Fix gosec warnings (MaxBytesReader for form parsing) - Fix staticcheck suggestions (fmt.Fprintf instead of WriteString) - Rename DeliveryTask to Task to avoid stutter (delivery.Task) - Rename shadowed builtin 'max' parameter - Update README.md version requirements
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
@@ -20,6 +21,23 @@ const (
|
||||
argon2SaltLen = 16
|
||||
)
|
||||
|
||||
// hashParts is the expected number of $-separated segments
|
||||
// in an encoded Argon2id hash string.
|
||||
const hashParts = 6
|
||||
|
||||
// minPasswordComplexityLen is the minimum password length that
|
||||
// triggers per-character-class complexity enforcement.
|
||||
const minPasswordComplexityLen = 4
|
||||
|
||||
// Sentinel errors returned by decodeHash.
|
||||
var (
|
||||
errInvalidHashFormat = errors.New("invalid hash format")
|
||||
errInvalidAlgorithm = errors.New("invalid algorithm")
|
||||
errIncompatibleVersion = errors.New("incompatible argon2 version")
|
||||
errSaltLengthOutOfRange = errors.New("salt length out of range")
|
||||
errHashLengthOutOfRange = errors.New("hash length out of range")
|
||||
)
|
||||
|
||||
// PasswordConfig holds Argon2 configuration
|
||||
type PasswordConfig struct {
|
||||
Time uint32
|
||||
@@ -46,26 +64,44 @@ func HashPassword(password string) (string, error) {
|
||||
|
||||
// Generate a salt
|
||||
salt := make([]byte, config.SaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate the hash
|
||||
hash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
config.Time,
|
||||
config.Memory,
|
||||
config.Threads,
|
||||
config.KeyLen,
|
||||
)
|
||||
|
||||
// Encode the hash and parameters
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
// Format: $argon2id$v=19$m=65536,t=1,p=4$salt$hash
|
||||
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, config.Memory, config.Time, config.Threads, b64Salt, b64Hash)
|
||||
encoded := fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
config.Memory,
|
||||
config.Time,
|
||||
config.Threads,
|
||||
b64Salt,
|
||||
b64Hash,
|
||||
)
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if the provided password matches the hash
|
||||
func VerifyPassword(password, encodedHash string) (bool, error) {
|
||||
func VerifyPassword(
|
||||
password, encodedHash string,
|
||||
) (bool, error) {
|
||||
// Extract parameters and hash from encoded string
|
||||
config, salt, hash, err := decodeHash(encodedHash)
|
||||
if err != nil {
|
||||
@@ -73,60 +109,119 @@ func VerifyPassword(password, encodedHash string) (bool, error) {
|
||||
}
|
||||
|
||||
// Generate hash of the provided password
|
||||
otherHash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
|
||||
otherHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
config.Time,
|
||||
config.Memory,
|
||||
config.Threads,
|
||||
config.KeyLen,
|
||||
)
|
||||
|
||||
// Compare hashes using constant time comparison
|
||||
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
|
||||
}
|
||||
|
||||
// decodeHash extracts parameters, salt, and hash from an encoded hash string
|
||||
func decodeHash(encodedHash string) (*PasswordConfig, []byte, []byte, error) {
|
||||
// decodeHash extracts parameters, salt, and hash from an
|
||||
// encoded hash string.
|
||||
func decodeHash(
|
||||
encodedHash string,
|
||||
) (*PasswordConfig, []byte, []byte, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return nil, nil, nil, fmt.Errorf("invalid hash format")
|
||||
if len(parts) != hashParts {
|
||||
return nil, nil, nil, errInvalidHashFormat
|
||||
}
|
||||
|
||||
if parts[1] != "argon2id" {
|
||||
return nil, nil, nil, fmt.Errorf("invalid algorithm")
|
||||
return nil, nil, nil, errInvalidAlgorithm
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
version, err := parseVersion(parts[2])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if version != argon2.Version {
|
||||
return nil, nil, nil, fmt.Errorf("incompatible argon2 version")
|
||||
return nil, nil, nil, errIncompatibleVersion
|
||||
}
|
||||
|
||||
config := &PasswordConfig{}
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &config.Memory, &config.Time, &config.Threads); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
config, err := parseParams(parts[3])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
saltLen := len(salt)
|
||||
if saltLen < 0 || saltLen > int(^uint32(0)) {
|
||||
return nil, nil, nil, fmt.Errorf("salt length out of range")
|
||||
}
|
||||
config.SaltLen = uint32(saltLen) // nolint:gosec // checked above
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
salt, err := decodeSalt(parts[4])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
hashLen := len(hash)
|
||||
if hashLen < 0 || hashLen > int(^uint32(0)) {
|
||||
return nil, nil, nil, fmt.Errorf("hash length out of range")
|
||||
|
||||
config.SaltLen = uint32(len(salt)) //nolint:gosec // validated in decodeSalt
|
||||
|
||||
hash, err := decodeHashBytes(parts[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
config.KeyLen = uint32(hashLen) // nolint:gosec // checked above
|
||||
|
||||
config.KeyLen = uint32(len(hash)) //nolint:gosec // validated in decodeHashBytes
|
||||
|
||||
return config, salt, hash, nil
|
||||
}
|
||||
|
||||
// GenerateRandomPassword generates a cryptographically secure random password
|
||||
func parseVersion(s string) (int, error) {
|
||||
var version int
|
||||
|
||||
_, err := fmt.Sscanf(s, "v=%d", &version)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing version: %w", err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func parseParams(s string) (*PasswordConfig, error) {
|
||||
config := &PasswordConfig{}
|
||||
|
||||
_, err := fmt.Sscanf(
|
||||
s, "m=%d,t=%d,p=%d",
|
||||
&config.Memory, &config.Time, &config.Threads,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing params: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func decodeSalt(s string) ([]byte, error) {
|
||||
salt, err := base64.RawStdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding salt: %w", err)
|
||||
}
|
||||
|
||||
saltLen := len(salt)
|
||||
if saltLen < 0 || saltLen > int(^uint32(0)) {
|
||||
return nil, errSaltLengthOutOfRange
|
||||
}
|
||||
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
func decodeHashBytes(s string) ([]byte, error) {
|
||||
hash, err := base64.RawStdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding hash: %w", err)
|
||||
}
|
||||
|
||||
hashLen := len(hash)
|
||||
if hashLen < 0 || hashLen > int(^uint32(0)) {
|
||||
return nil, errHashLengthOutOfRange
|
||||
}
|
||||
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// GenerateRandomPassword generates a cryptographically secure
|
||||
// random password.
|
||||
func GenerateRandomPassword(length int) (string, error) {
|
||||
const (
|
||||
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
@@ -141,27 +236,27 @@ func GenerateRandomPassword(length int) (string, error) {
|
||||
// Create password slice
|
||||
password := make([]byte, length)
|
||||
|
||||
// Ensure at least one character from each set for password complexity
|
||||
if length >= 4 {
|
||||
// Get one character from each set
|
||||
// Ensure at least one character from each set
|
||||
if length >= minPasswordComplexityLen {
|
||||
password[0] = uppercase[cryptoRandInt(len(uppercase))]
|
||||
password[1] = lowercase[cryptoRandInt(len(lowercase))]
|
||||
password[2] = digits[cryptoRandInt(len(digits))]
|
||||
password[3] = special[cryptoRandInt(len(special))]
|
||||
|
||||
// Fill the rest randomly from all characters
|
||||
for i := 4; i < length; i++ {
|
||||
for i := minPasswordComplexityLen; i < length; i++ {
|
||||
password[i] = allChars[cryptoRandInt(len(allChars))]
|
||||
}
|
||||
|
||||
// Shuffle the password to avoid predictable pattern
|
||||
for i := len(password) - 1; i > 0; i-- {
|
||||
j := cryptoRandInt(i + 1)
|
||||
password[i], password[j] = password[j], password[i]
|
||||
for i := range len(password) - 1 {
|
||||
j := cryptoRandInt(len(password) - i)
|
||||
idx := len(password) - 1 - i
|
||||
password[idx], password[j] = password[j], password[idx]
|
||||
}
|
||||
} else {
|
||||
// For very short passwords, just use all characters
|
||||
for i := 0; i < length; i++ {
|
||||
for i := range length {
|
||||
password[i] = allChars[cryptoRandInt(len(allChars))]
|
||||
}
|
||||
}
|
||||
@@ -169,16 +264,17 @@ func GenerateRandomPassword(length int) (string, error) {
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
// cryptoRandInt generates a cryptographically secure random integer in [0, max)
|
||||
func cryptoRandInt(max int) int {
|
||||
if max <= 0 {
|
||||
panic("max must be positive")
|
||||
// cryptoRandInt generates a cryptographically secure random
|
||||
// integer in [0, upperBound).
|
||||
func cryptoRandInt(upperBound int) int {
|
||||
if upperBound <= 0 {
|
||||
panic("upperBound must be positive")
|
||||
}
|
||||
|
||||
// Calculate the maximum valid value to avoid modulo bias
|
||||
// For example, if max=200 and we have 256 possible values,
|
||||
// we only accept values 0-199 (reject 200-255)
|
||||
nBig, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||||
nBig, err := rand.Int(
|
||||
rand.Reader,
|
||||
big.NewInt(int64(upperBound)),
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("crypto/rand error: %v", err))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user