Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
149
cloud/maplepress-backend/pkg/security/password/breachcheck.go
Normal file
149
cloud/maplepress-backend/pkg/security/password/breachcheck.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/security/password/breachcheck.go
|
||||
package password
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPasswordBreached indicates the password has been found in known data breaches
|
||||
ErrPasswordBreached = fmt.Errorf("password has been found in data breaches")
|
||||
)
|
||||
|
||||
// BreachChecker checks if passwords have been compromised in known data breaches
|
||||
// using the Have I Been Pwned API's k-anonymity model
|
||||
type BreachChecker interface {
|
||||
// CheckPassword checks if a password has been breached
|
||||
// Returns the number of times the password was found in breaches (0 = safe)
|
||||
CheckPassword(ctx context.Context, password string) (int, error)
|
||||
|
||||
// IsPasswordBreached returns true if password has been found in breaches
|
||||
IsPasswordBreached(ctx context.Context, password string) (bool, error)
|
||||
}
|
||||
|
||||
type breachChecker struct {
|
||||
httpClient *http.Client
|
||||
apiURL string
|
||||
userAgent string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewBreachChecker creates a new password breach checker
|
||||
// CWE-521: Password breach checking using Have I Been Pwned API
|
||||
// Uses k-anonymity model - only sends first 5 characters of SHA-1 hash
|
||||
func NewBreachChecker(logger *zap.Logger) BreachChecker {
|
||||
return &breachChecker{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
apiURL: "https://api.pwnedpasswords.com/range/",
|
||||
userAgent: "MaplePress-Backend-Password-Checker",
|
||||
logger: logger.Named("breach-checker"),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPassword checks if a password has been breached using HIBP k-anonymity API
|
||||
// Returns the number of times the password appears in breaches (0 = safe)
|
||||
// CWE-521: This implements password breach checking without sending the full password
|
||||
func (bc *breachChecker) CheckPassword(ctx context.Context, password string) (int, error) {
|
||||
// Step 1: SHA-1 hash the password
|
||||
hash := sha1.Sum([]byte(password))
|
||||
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
|
||||
|
||||
// Step 2: Take first 5 characters (k-anonymity prefix)
|
||||
prefix := hashStr[:5]
|
||||
suffix := hashStr[5:]
|
||||
|
||||
// Step 3: Query HIBP API with prefix only
|
||||
url := bc.apiURL + prefix
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
bc.logger.Error("failed to create HIBP request", zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set User-Agent as required by HIBP API
|
||||
req.Header.Set("User-Agent", bc.userAgent)
|
||||
req.Header.Set("Add-Padding", "true") // Request padding for additional privacy
|
||||
|
||||
bc.logger.Debug("checking password against HIBP",
|
||||
zap.String("prefix", prefix))
|
||||
|
||||
resp, err := bc.httpClient.Do(req)
|
||||
if err != nil {
|
||||
bc.logger.Error("failed to query HIBP API", zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to query breach database: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
bc.logger.Error("received nil response from HIBP API")
|
||||
return 0, fmt.Errorf("received nil response from breach database")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bc.logger.Error("HIBP API returned non-OK status",
|
||||
zap.Int("status", resp.StatusCode))
|
||||
return 0, fmt.Errorf("breach database returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Step 4: Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
bc.logger.Error("failed to read HIBP response", zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Parse response and look for our suffix
|
||||
// Response format: SUFFIX:COUNT\r\n for each hash
|
||||
lines := strings.Split(string(body), "\r\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is our hash
|
||||
if parts[0] == suffix {
|
||||
count, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
bc.logger.Warn("failed to parse breach count",
|
||||
zap.String("line", line),
|
||||
zap.Error(err))
|
||||
return 0, fmt.Errorf("failed to parse breach count: %w", err)
|
||||
}
|
||||
|
||||
bc.logger.Warn("password found in data breaches",
|
||||
zap.Int("breach_count", count))
|
||||
return count, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Password not found in breaches
|
||||
bc.logger.Debug("password not found in breaches")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// IsPasswordBreached returns true if password has been found in data breaches
|
||||
// This is a convenience wrapper around CheckPassword
|
||||
func (bc *breachChecker) IsPasswordBreached(ctx context.Context, password string) (bool, error) {
|
||||
count, err := bc.CheckPassword(ctx, password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
200
cloud/maplepress-backend/pkg/security/password/password.go
Normal file
200
cloud/maplepress-backend/pkg/security/password/password.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/security/password/password.go
|
||||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securestring"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidHash = errors.New("the encoded hash is not in the correct format")
|
||||
ErrIncompatibleVersion = errors.New("incompatible version of argon2")
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrPasswordTooLong = errors.New("password must not exceed 128 characters")
|
||||
|
||||
// Granular password strength errors (CWE-521: Weak Password Requirements)
|
||||
ErrPasswordNoUppercase = errors.New("password must contain at least one uppercase letter (A-Z)")
|
||||
ErrPasswordNoLowercase = errors.New("password must contain at least one lowercase letter (a-z)")
|
||||
ErrPasswordNoNumber = errors.New("password must contain at least one number (0-9)")
|
||||
ErrPasswordNoSpecialChar = errors.New("password must contain at least one special character (!@#$%^&*()_+-=[]{}; etc.)")
|
||||
ErrPasswordTooWeak = errors.New("password must contain uppercase, lowercase, number, and special character")
|
||||
)
|
||||
|
||||
// PasswordProvider provides secure password hashing and verification using Argon2id.
|
||||
type PasswordProvider interface {
|
||||
GenerateHashFromPassword(password *securestring.SecureString) (string, error)
|
||||
ComparePasswordAndHash(password *securestring.SecureString, hash string) (bool, error)
|
||||
AlgorithmName() string
|
||||
GenerateSecureRandomBytes(length int) ([]byte, error)
|
||||
GenerateSecureRandomString(length int) (string, error)
|
||||
}
|
||||
|
||||
type passwordProvider struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
parallelism uint8
|
||||
saltLength uint32
|
||||
keyLength uint32
|
||||
}
|
||||
|
||||
// NewPasswordProvider creates a new password provider with secure default parameters.
|
||||
// The default parameters are based on OWASP recommendations for Argon2id:
|
||||
// - Memory: 64 MB
|
||||
// - Iterations: 3
|
||||
// - Parallelism: 2
|
||||
// - Salt length: 16 bytes
|
||||
// - Key length: 32 bytes
|
||||
func NewPasswordProvider() PasswordProvider {
|
||||
// DEVELOPERS NOTE:
|
||||
// The following code was adapted from: "How to Hash and Verify Passwords With Argon2 in Go"
|
||||
// via https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go
|
||||
|
||||
// Establish the parameters to use for Argon2
|
||||
return &passwordProvider{
|
||||
memory: 64 * 1024, // 64 MB
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateHashFromPassword takes a secure string and returns an Argon2id hashed string.
|
||||
// The returned hash string includes all parameters needed for verification:
|
||||
// Format: $argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>
|
||||
func (p *passwordProvider) GenerateHashFromPassword(password *securestring.SecureString) (string, error) {
|
||||
salt, err := generateRandomBytes(p.saltLength)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
passwordBytes := password.Bytes()
|
||||
|
||||
// Generate the hash using Argon2id
|
||||
hash := argon2.IDKey(passwordBytes, salt, p.iterations, p.memory, p.parallelism, p.keyLength)
|
||||
|
||||
// Base64 encode the salt and hashed password
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
// Return a string using the standard encoded hash representation
|
||||
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash)
|
||||
|
||||
return encodedHash, nil
|
||||
}
|
||||
|
||||
// ComparePasswordAndHash verifies that a password matches the provided hash.
|
||||
// It uses constant-time comparison to prevent timing attacks.
|
||||
// Returns true if the password matches, false otherwise.
|
||||
func (p *passwordProvider) ComparePasswordAndHash(password *securestring.SecureString, encodedHash string) (match bool, err error) {
|
||||
// DEVELOPERS NOTE:
|
||||
// The following code was adapted from: "How to Hash and Verify Passwords With Argon2 in Go"
|
||||
// via https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go
|
||||
|
||||
// Extract the parameters, salt and derived key from the encoded password hash
|
||||
params, salt, hash, err := decodeHash(encodedHash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Derive the key from the password using the same parameters
|
||||
otherHash := argon2.IDKey(password.Bytes(), salt, params.iterations, params.memory, params.parallelism, params.keyLength)
|
||||
|
||||
// Check that the contents of the hashed passwords are identical
|
||||
// Using subtle.ConstantTimeCompare() to help prevent timing attacks
|
||||
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AlgorithmName returns the name of the hashing algorithm used.
|
||||
func (p *passwordProvider) AlgorithmName() string {
|
||||
return "argon2id"
|
||||
}
|
||||
|
||||
// GenerateSecureRandomBytes generates a cryptographically secure random byte slice.
|
||||
func (p *passwordProvider) GenerateSecureRandomBytes(length int) ([]byte, error) {
|
||||
bytes := make([]byte, length)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate secure random bytes: %w", err)
|
||||
}
|
||||
return bytes, nil
|
||||
}
|
||||
|
||||
// GenerateSecureRandomString generates a cryptographically secure random hex string.
|
||||
// The returned string will be twice the length parameter (2 hex chars per byte).
|
||||
func (p *passwordProvider) GenerateSecureRandomString(length int) (string, error) {
|
||||
bytes, err := p.GenerateSecureRandomBytes(length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateRandomBytes generates cryptographically secure random bytes.
|
||||
func generateRandomBytes(n uint32) ([]byte, error) {
|
||||
// DEVELOPERS NOTE:
|
||||
// The following code was adapted from: "How to Hash and Verify Passwords With Argon2 in Go"
|
||||
// via https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go
|
||||
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// decodeHash extracts the parameters, salt, and hash from an encoded hash string.
|
||||
func decodeHash(encodedHash string) (p *passwordProvider, salt, hash []byte, err error) {
|
||||
// DEVELOPERS NOTE:
|
||||
// The following code was adapted from: "How to Hash and Verify Passwords With Argon2 in Go"
|
||||
// via https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go
|
||||
|
||||
vals := strings.Split(encodedHash, "$")
|
||||
if len(vals) != 6 {
|
||||
return nil, nil, nil, ErrInvalidHash
|
||||
}
|
||||
|
||||
var version int
|
||||
_, err = fmt.Sscanf(vals[2], "v=%d", &version)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return nil, nil, nil, ErrIncompatibleVersion
|
||||
}
|
||||
|
||||
p = &passwordProvider{}
|
||||
_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
p.saltLength = uint32(len(salt))
|
||||
|
||||
hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
p.keyLength = uint32(len(hash))
|
||||
|
||||
return p, salt, hash, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package password
|
||||
|
||||
// ProvidePasswordProvider creates a new password provider instance.
|
||||
func ProvidePasswordProvider() PasswordProvider {
|
||||
return NewPasswordProvider()
|
||||
}
|
||||
44
cloud/maplepress-backend/pkg/security/password/timing.go
Normal file
44
cloud/maplepress-backend/pkg/security/password/timing.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/pkg/security/password/timing.go
|
||||
package password
|
||||
|
||||
import (
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securestring"
|
||||
)
|
||||
|
||||
// DummyPasswordHash is a pre-computed valid Argon2id hash used for timing attack mitigation
|
||||
// This hash is computed with the same parameters as real password hashes
|
||||
// CWE-208: Observable Timing Discrepancy - Prevents user enumeration via timing attacks
|
||||
const DummyPasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$c29tZXJhbmRvbXNhbHQxMjM0$kixiIQQ/y8E7dSH0j8p8KPBUlCMUGQOvH2kP7XYPkVs"
|
||||
|
||||
// ComparePasswordWithDummy performs password comparison but always uses a dummy hash
|
||||
// This is used when a user doesn't exist to maintain constant time behavior
|
||||
// CWE-208: Observable Timing Discrepancy - Mitigates timing-based user enumeration
|
||||
func (p *passwordProvider) ComparePasswordWithDummy(password *securestring.SecureString) error {
|
||||
// Perform the same expensive operation (Argon2 hashing) even for non-existent users
|
||||
// This ensures the timing is constant regardless of whether the user exists
|
||||
_, _ = p.ComparePasswordAndHash(password, DummyPasswordHash)
|
||||
|
||||
// Always return false (user doesn't exist, so authentication always fails)
|
||||
// The important part is that we spent the same amount of time
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimingSafeCompare performs a timing-safe password comparison
|
||||
// It always performs the password hashing operation regardless of whether
|
||||
// the user exists or the password matches
|
||||
// CWE-208: Observable Timing Discrepancy - Prevents timing attacks
|
||||
func TimingSafeCompare(provider PasswordProvider, password *securestring.SecureString, hash string, userExists bool) (bool, error) {
|
||||
if !userExists {
|
||||
// User doesn't exist - perform dummy hash comparison to maintain constant time
|
||||
if pp, ok := provider.(*passwordProvider); ok {
|
||||
_ = pp.ComparePasswordWithDummy(password)
|
||||
} else {
|
||||
// Fallback if type assertion fails
|
||||
_, _ = provider.ComparePasswordAndHash(password, DummyPasswordHash)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// User exists - perform real comparison
|
||||
return provider.ComparePasswordAndHash(password, hash)
|
||||
}
|
||||
90
cloud/maplepress-backend/pkg/security/password/validator.go
Normal file
90
cloud/maplepress-backend/pkg/security/password/validator.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package password
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
// MinPasswordLength is the minimum required password length
|
||||
MinPasswordLength = 8
|
||||
// MaxPasswordLength is the maximum allowed password length
|
||||
MaxPasswordLength = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// Special characters allowed in passwords
|
||||
specialCharRegex = regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`)
|
||||
)
|
||||
|
||||
// PasswordValidator provides password strength validation
|
||||
type PasswordValidator interface {
|
||||
ValidatePasswordStrength(password string) error
|
||||
}
|
||||
|
||||
type passwordValidator struct{}
|
||||
|
||||
// NewPasswordValidator creates a new password validator
|
||||
func NewPasswordValidator() PasswordValidator {
|
||||
return &passwordValidator{}
|
||||
}
|
||||
|
||||
// ValidatePasswordStrength validates that a password meets strength requirements
|
||||
// Requirements:
|
||||
// - At least 8 characters long
|
||||
// - At most 128 characters long
|
||||
// - Contains at least one uppercase letter
|
||||
// - Contains at least one lowercase letter
|
||||
// - Contains at least one digit
|
||||
// - Contains at least one special character
|
||||
//
|
||||
// CWE-521: Returns granular error messages to help users create strong passwords
|
||||
func (v *passwordValidator) ValidatePasswordStrength(password string) error {
|
||||
// Check length first
|
||||
if len(password) < MinPasswordLength {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
|
||||
if len(password) > MaxPasswordLength {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
|
||||
// Check character type requirements
|
||||
var (
|
||||
hasUpper bool
|
||||
hasLower bool
|
||||
hasNumber bool
|
||||
hasSpecial bool
|
||||
)
|
||||
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsNumber(char):
|
||||
hasNumber = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for special characters
|
||||
hasSpecial = specialCharRegex.MatchString(password)
|
||||
|
||||
// Return granular error for the first missing requirement
|
||||
// This provides specific feedback to users about what's missing
|
||||
if !hasUpper {
|
||||
return ErrPasswordNoUppercase
|
||||
}
|
||||
if !hasLower {
|
||||
return ErrPasswordNoLowercase
|
||||
}
|
||||
if !hasNumber {
|
||||
return ErrPasswordNoNumber
|
||||
}
|
||||
if !hasSpecial {
|
||||
return ErrPasswordNoSpecialChar
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue