Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

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

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

View file

@ -0,0 +1,6 @@
package password
// ProvidePasswordProvider creates a new password provider instance.
func ProvidePasswordProvider() PasswordProvider {
return NewPasswordProvider()
}

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

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