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,520 @@
# Security Package
This package provides secure password hashing and memory-safe storage for sensitive data.
## Packages
### Password (`pkg/security/password`)
Provides Argon2id-based password hashing and verification with secure default parameters following OWASP recommendations.
### SecureString (`pkg/security/securestring`)
Memory-safe string storage using `memguard` to protect sensitive data like passwords and API keys from memory dumps and swap files.
### SecureBytes (`pkg/security/securebytes`)
Memory-safe byte slice storage using `memguard` to protect sensitive binary data.
### IPCountryBlocker (`pkg/security/ipcountryblocker`)
GeoIP-based country blocking using MaxMind's GeoLite2 database to block requests from specific countries.
## Installation
The packages are included in the project. Required dependencies:
- `github.com/awnumar/memguard` - For secure memory management
- `golang.org/x/crypto/argon2` - For password hashing
- `github.com/oschwald/geoip2-golang` - For GeoIP lookups
## Usage
### Password Hashing
```go
import (
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/password"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securestring"
)
// Create password provider
passwordProvider := password.NewPasswordProvider()
// Hash a password
plainPassword := "mySecurePassword123!"
securePass, err := securestring.NewSecureString(plainPassword)
if err != nil {
// Handle error
}
defer securePass.Wipe() // Always wipe after use
hashedPassword, err := passwordProvider.GenerateHashFromPassword(securePass)
if err != nil {
// Handle error
}
// Verify a password
match, err := passwordProvider.ComparePasswordAndHash(securePass, hashedPassword)
if err != nil {
// Handle error
}
if match {
// Password is correct
}
```
### Secure String Storage
```go
import "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securestring"
// Store sensitive data securely
apiKey := "secret-api-key-12345"
secureKey, err := securestring.NewSecureString(apiKey)
if err != nil {
// Handle error
}
defer secureKey.Wipe() // Always wipe when done
// Use the secure string
keyValue := secureKey.String() // Get the value when needed
// ... use keyValue ...
// The original string should be cleared
apiKey = ""
```
### Secure Bytes Storage
```go
import "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/securebytes"
// Store sensitive binary data
sensitiveData := []byte{0x01, 0x02, 0x03, 0x04}
secureData, err := securebytes.NewSecureBytes(sensitiveData)
if err != nil {
// Handle error
}
defer secureData.Wipe()
// Use the secure bytes
data := secureData.Bytes()
// ... use data ...
// Clear the original slice
for i := range sensitiveData {
sensitiveData[i] = 0
}
```
### Generate Random Values
```go
passwordProvider := password.NewPasswordProvider()
// Generate random bytes
randomBytes, err := passwordProvider.GenerateSecureRandomBytes(32)
// Generate random hex string (length * 2 characters)
randomString, err := passwordProvider.GenerateSecureRandomString(16)
// Returns a 32-character hex string
```
### IP Country Blocking
```go
import (
"context"
"net"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcountryblocker"
)
// Create the blocker (typically done via dependency injection)
cfg, _ := config.Load()
blocker := ipcountryblocker.NewProvider(cfg, logger)
defer blocker.Close()
// Check if an IP is blocked
ip := net.ParseIP("192.0.2.1")
if blocker.IsBlockedIP(context.Background(), ip) {
// Handle blocked IP
return errors.New("access denied: your country is blocked")
}
// Check if a country code is blocked
if blocker.IsBlockedCountry("CN") {
// Country is in the blocked list
}
// Get country code for an IP
countryCode, err := blocker.GetCountryCode(context.Background(), ip)
if err != nil {
// Handle error
}
// countryCode will be ISO 3166-1 alpha-2 code like "US", "CA", "GB"
```
**Configuration**:
```bash
# Environment variables
APP_GEOLITE_DB_PATH=/path/to/GeoLite2-Country.mmdb
APP_BANNED_COUNTRIES=CN,RU,KP # Comma-separated ISO 3166-1 alpha-2 codes
```
## Password Hashing Details
### Algorithm: Argon2id
Argon2id is the recommended password hashing algorithm by OWASP. It combines:
- Argon2i: Resistant to side-channel attacks
- Argon2d: Resistant to GPU cracking attacks
### Default Parameters
```
Memory: 64 MB (65536 KB)
Iterations: 3
Parallelism: 2 threads
Salt Length: 16 bytes
Key Length: 32 bytes
```
These parameters provide strong security while maintaining reasonable performance for authentication systems.
### Hash Format
```
$argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>
```
Example:
```
$argon2id$v=19$m=65536,t=3,p=2$YWJjZGVmZ2hpamtsbW5vcA$9XJqrJ8fQvVrMz0FqJ7gBGqKvYLvLxC8HzPqKvYLvLxC
```
The hash includes all parameters, so it can be verified even if you change the default parameters later.
## Security Best Practices
### 1. Always Wipe Sensitive Data
```go
securePass, _ := securestring.NewSecureString(password)
defer securePass.Wipe() // Ensures cleanup even on panic
// ... use securePass ...
```
### 2. Clear Original Data
After creating a secure string/bytes, clear the original data:
```go
password := "secret"
securePass, _ := securestring.NewSecureString(password)
password = "" // Clear the original string
// Even better for byte slices:
data := []byte("secret")
secureData, _ := securebytes.NewSecureBytes(data)
for i := range data {
data[i] = 0
}
```
### 3. Minimize Exposure Time
Get values from secure storage only when needed:
```go
// Bad - exposes value for too long
value := secureString.String()
// ... lots of code ...
useValue(value)
// Good - get value right before use
// ... lots of code ...
useValue(secureString.String())
```
### 4. Use Dependency Injection
```go
import "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/password"
// In your Wire provider set
wire.NewSet(
password.ProvidePasswordProvider,
// ... other providers
)
// Use in your service
type AuthService struct {
passwordProvider password.PasswordProvider
}
func NewAuthService(pp password.PasswordProvider) *AuthService {
return &AuthService{passwordProvider: pp}
}
```
### 5. Handle Errors Properly
```go
securePass, err := securestring.NewSecureString(password)
if err != nil {
return fmt.Errorf("failed to create secure string: %w", err)
}
defer securePass.Wipe()
```
### 6. Clean Up GeoIP Resources
```go
import "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcountryblocker"
// Always close the provider when done to release database resources
blocker := ipcountryblocker.NewProvider(cfg, logger)
defer blocker.Close()
```
## Memory Safety
### How It Works
The `memguard` library provides:
1. **Locked Memory**: Prevents sensitive data from being swapped to disk
2. **Guarded Heap**: Detects buffer overflows and underflows
3. **Secure Wiping**: Overwrites memory with random data before freeing
4. **Read Protection**: Makes memory pages read-only when not in use
### When to Use
Use secure storage for:
- Passwords and password hashes (during verification)
- API keys and tokens
- Encryption keys
- Private keys
- Database credentials
- OAuth secrets
- JWT signing keys
- Session tokens
- Any sensitive user data
### When NOT to Use
Don't use for:
- Public data
- Non-sensitive configuration
- Data that needs to be logged
- Data that will be stored long-term in memory
## Performance Considerations
### Password Hashing
Argon2id is intentionally slow to prevent brute-force attacks:
- Expected time: ~50-100ms per hash on modern hardware
- This is acceptable for authentication (login) operations
- DO NOT use for high-throughput operations
### Memory Usage
SecureString/SecureBytes use locked memory:
- Each instance locks a page in RAM (typically 4KB minimum)
- Don't create thousands of instances
- Reuse instances when possible
- Always wipe when done
## Examples
### Complete Login Example
```go
func (s *AuthService) Login(ctx context.Context, email, password string) (*User, error) {
// Create secure string from password
securePass, err := securestring.NewSecureString(password)
if err != nil {
return nil, err
}
defer securePass.Wipe()
// Clear the original password
password = ""
// Get user from database
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
return nil, err
}
// Verify password
match, err := s.passwordProvider.ComparePasswordAndHash(securePass, user.PasswordHash)
if err != nil {
return nil, err
}
if !match {
return nil, ErrInvalidCredentials
}
return user, nil
}
```
### Complete Registration Example
```go
func (s *AuthService) Register(ctx context.Context, email, password string) (*User, error) {
// Validate password strength first
if len(password) < 8 {
return nil, ErrWeakPassword
}
// Create secure string from password
securePass, err := securestring.NewSecureString(password)
if err != nil {
return nil, err
}
defer securePass.Wipe()
// Clear the original password
password = ""
// Hash the password
hashedPassword, err := s.passwordProvider.GenerateHashFromPassword(securePass)
if err != nil {
return nil, err
}
// Create user with hashed password
user := &User{
Email: email,
PasswordHash: hashedPassword,
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
```
## Troubleshooting
### "failed to create buffer"
**Problem**: memguard couldn't allocate locked memory
**Solutions**:
- Check system limits for locked memory (`ulimit -l`)
- Reduce number of concurrent SecureString/SecureBytes instances
- Ensure proper cleanup with `Wipe()`
### "buffer is not alive"
**Problem**: Trying to use a SecureString/SecureBytes after it was wiped
**Solutions**:
- Don't use secure data after calling `Wipe()`
- Check your defer ordering
- Create new instances if you need the data again
### Slow Performance
**Problem**: Password hashing is too slow
**Solutions**:
- This is by design for security
- Don't hash passwords in high-throughput operations
- Consider caching authentication results (with care)
- Use async operations for registration/password changes
### "failed to open GeoLite2 DB"
**Problem**: Cannot open the GeoIP2 database
**Solutions**:
- Verify APP_GEOLITE_DB_PATH points to a valid .mmdb file
- Download the GeoLite2-Country database from MaxMind
- Check file permissions
- Ensure the database file is not corrupted
### "no country found for IP"
**Problem**: IP address lookup returns no country
**Solutions**:
- This is normal for private IP ranges (10.x.x.x, 192.168.x.x, etc.)
- The IP might not be in the GeoIP2 database
- Update to a newer GeoLite2 database
- By default, unknown IPs are allowed (returns false from IsBlockedIP)
## IP Country Blocking Details
### GeoLite2 Database
The IP country blocker uses MaxMind's GeoLite2-Country database for IP geolocation.
**How to Get the Database**:
1. Create a free account at https://www.maxmind.com/en/geolite2/signup
2. Generate a license key
3. Download GeoLite2-Country database (.mmdb format)
4. Set APP_GEOLITE_DB_PATH to the file location
**Database Updates**:
- MaxMind updates GeoLite2 databases weekly
- Set up automated updates for production systems
- Database file is typically 5-10 MB
### Country Codes
Uses ISO 3166-1 alpha-2 country codes:
- US - United States
- CA - Canada
- GB - United Kingdom
- CN - China
- RU - Russia
- KP - North Korea
- etc.
Full list: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
### Blocking Behavior
**Default Behavior**:
- If IP lookup fails → Allow (returns false)
- If country not found → Allow (returns false)
- If country is blocked → Block (returns true)
**To block unknown IPs**, modify IsBlockedIP to return true on error (line 101 in ipcountryblocker.go).
### Thread Safety
The provider is thread-safe:
- Uses sync.RWMutex for concurrent access to blocked countries map
- GeoIP2 Reader is thread-safe by design
- Safe to use in HTTP middleware and concurrent handlers
### Performance
**Lookup Speed**:
- In-memory database lookups are very fast (~microseconds)
- Database is memory-mapped for efficiency
- Suitable for high-traffic applications
**Memory Usage**:
- GeoLite2-Country database: ~5-10 MB in memory
- Blocked countries map: negligible (few KB)
## References
- [Argon2 RFC](https://tools.ietf.org/html/rfc9106)
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- [memguard Documentation](https://github.com/awnumar/memguard)
- [Alex Edwards: How to Hash and Verify Passwords With Argon2 in Go](https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go)
- [MaxMind GeoLite2 Free Geolocation Data](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data)
- [ISO 3166-1 Country Codes](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)

View file

@ -0,0 +1,96 @@
package apikey
import (
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
)
const (
// PrefixLive is the prefix for production API keys
PrefixLive = "live_sk_"
// PrefixTest is the prefix for test/sandbox API keys
PrefixTest = "test_sk_"
// KeyLength is the length of the random part (40 chars in base64url)
KeyLength = 30 // 30 bytes = 40 base64url chars
)
// Generator generates API keys
type Generator interface {
// Generate creates a new live API key
Generate() (string, error)
// GenerateTest creates a new test API key
GenerateTest() (string, error)
}
type generator struct{}
// NewGenerator creates a new API key generator
func NewGenerator() Generator {
return &generator{}
}
// Generate creates a new live API key
func (g *generator) Generate() (string, error) {
return g.generateWithPrefix(PrefixLive)
}
// GenerateTest creates a new test API key
func (g *generator) GenerateTest() (string, error) {
return g.generateWithPrefix(PrefixTest)
}
func (g *generator) generateWithPrefix(prefix string) (string, error) {
// Generate cryptographically secure random bytes
b := make([]byte, KeyLength)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode to base64url (URL-safe, no padding)
key := base64.RawURLEncoding.EncodeToString(b)
// Remove any special chars and make lowercase for consistency
key = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return -1 // Remove character
}, key)
// Ensure we have at least 40 characters
if len(key) < 40 {
// Pad with additional random bytes if needed
additional := make([]byte, 10)
rand.Read(additional)
extraKey := base64.RawURLEncoding.EncodeToString(additional)
key += extraKey
}
// Trim to exactly 40 characters
key = key[:40]
return prefix + key, nil
}
// ExtractPrefix extracts the prefix from an API key
func ExtractPrefix(apiKey string) string {
if len(apiKey) < 13 {
return ""
}
return apiKey[:13] // "live_sk_a1b2" or "test_sk_a1b2"
}
// ExtractLastFour extracts the last 4 characters from an API key
func ExtractLastFour(apiKey string) string {
if len(apiKey) < 4 {
return ""
}
return apiKey[len(apiKey)-4:]
}
// IsValid checks if an API key has a valid format
func IsValid(apiKey string) bool {
return strings.HasPrefix(apiKey, PrefixLive) || strings.HasPrefix(apiKey, PrefixTest)
}

View file

@ -0,0 +1,35 @@
package apikey
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
)
// Hasher hashes and verifies API keys using SHA-256
type Hasher interface {
// Hash creates a deterministic SHA-256 hash of the API key
Hash(apiKey string) string
// Verify checks if the API key matches the hash using constant-time comparison
Verify(apiKey string, hash string) bool
}
type hasher struct{}
// NewHasher creates a new API key hasher
func NewHasher() Hasher {
return &hasher{}
}
// Hash creates a deterministic SHA-256 hash of the API key
func (h *hasher) Hash(apiKey string) string {
hash := sha256.Sum256([]byte(apiKey))
return base64.StdEncoding.EncodeToString(hash[:])
}
// Verify checks if the API key matches the hash using constant-time comparison
// This prevents timing attacks
func (h *hasher) Verify(apiKey string, expectedHash string) bool {
actualHash := h.Hash(apiKey)
return subtle.ConstantTimeCompare([]byte(actualHash), []byte(expectedHash)) == 1
}

View file

@ -0,0 +1,11 @@
package apikey
// ProvideGenerator provides an API key generator for dependency injection
func ProvideGenerator() Generator {
return NewGenerator()
}
// ProvideHasher provides an API key hasher for dependency injection
func ProvideHasher() Hasher {
return NewHasher()
}

View file

@ -0,0 +1,168 @@
package clientip
import (
"net"
"net/http"
"strings"
"go.uber.org/zap"
)
// Extractor provides secure client IP address extraction
// CWE-348: Prevents X-Forwarded-For header spoofing by validating trusted proxies
type Extractor struct {
trustedProxies []*net.IPNet
logger *zap.Logger
}
// NewExtractor creates a new IP extractor with trusted proxy configuration
// trustedProxyCIDRs should contain CIDR blocks of trusted reverse proxies
// Example: []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}
func NewExtractor(trustedProxyCIDRs []string, logger *zap.Logger) (*Extractor, error) {
var trustedProxies []*net.IPNet
for _, cidr := range trustedProxyCIDRs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
logger.Error("failed to parse trusted proxy CIDR",
zap.String("cidr", cidr),
zap.Error(err))
return nil, err
}
trustedProxies = append(trustedProxies, ipNet)
}
logger.Info("client IP extractor initialized",
zap.Int("trusted_proxy_ranges", len(trustedProxies)))
return &Extractor{
trustedProxies: trustedProxies,
logger: logger.Named("client-ip-extractor"),
}, nil
}
// NewDefaultExtractor creates an extractor with no trusted proxies
// This is safe for direct connections but will ignore X-Forwarded-For headers
func NewDefaultExtractor(logger *zap.Logger) *Extractor {
logger.Warn("client IP extractor initialized with NO trusted proxies - X-Forwarded-For will be ignored")
return &Extractor{
trustedProxies: []*net.IPNet{},
logger: logger.Named("client-ip-extractor"),
}
}
// Extract extracts the real client IP address from the HTTP request
// CWE-348: Secure implementation that prevents header spoofing
func (e *Extractor) Extract(r *http.Request) string {
// Step 1: Get the immediate connection's remote address
remoteAddr := r.RemoteAddr
// Remove port from RemoteAddr (format: "IP:port" or "[IPv6]:port")
remoteIP := e.stripPort(remoteAddr)
// Step 2: Parse the remote IP
parsedRemoteIP := net.ParseIP(remoteIP)
if parsedRemoteIP == nil {
e.logger.Warn("failed to parse remote IP address",
zap.String("remote_addr", remoteAddr))
return remoteIP // Return as-is if we can't parse it
}
// Step 3: Check if the immediate connection is from a trusted proxy
if !e.isTrustedProxy(parsedRemoteIP) {
// NOT from a trusted proxy - do NOT trust X-Forwarded-For header
// This prevents clients from spoofing their IP by setting the header
e.logger.Debug("remote IP is not a trusted proxy, using RemoteAddr",
zap.String("remote_ip", remoteIP))
return remoteIP
}
// Step 4: Remote IP is trusted, check X-Forwarded-For header
// Format: "client, proxy1, proxy2" (leftmost is original client)
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
// No X-Forwarded-For header, use RemoteAddr
e.logger.Debug("no X-Forwarded-For header from trusted proxy",
zap.String("remote_ip", remoteIP))
return remoteIP
}
// Step 5: Parse X-Forwarded-For header
// Take the FIRST IP (leftmost) which should be the original client
ips := strings.Split(xff, ",")
if len(ips) == 0 {
e.logger.Debug("empty X-Forwarded-For header",
zap.String("remote_ip", remoteIP))
return remoteIP
}
// Get the first IP and trim whitespace
clientIP := strings.TrimSpace(ips[0])
// Step 6: Validate the client IP
parsedClientIP := net.ParseIP(clientIP)
if parsedClientIP == nil {
e.logger.Warn("invalid IP in X-Forwarded-For header",
zap.String("xff", xff),
zap.String("client_ip", clientIP))
return remoteIP // Fall back to RemoteAddr
}
e.logger.Debug("extracted client IP from X-Forwarded-For",
zap.String("client_ip", clientIP),
zap.String("remote_proxy", remoteIP),
zap.String("xff_chain", xff))
return clientIP
}
// ExtractOrDefault extracts the client IP or returns a default value
func (e *Extractor) ExtractOrDefault(r *http.Request, defaultIP string) string {
ip := e.Extract(r)
if ip == "" {
return defaultIP
}
return ip
}
// isTrustedProxy checks if an IP is in the trusted proxy list
func (e *Extractor) isTrustedProxy(ip net.IP) bool {
for _, ipNet := range e.trustedProxies {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// stripPort removes the port from an address string
// Handles both IPv4 (192.168.1.1:8080) and IPv6 ([::1]:8080) formats
func (e *Extractor) stripPort(addr string) string {
// For IPv6, check for bracket format [IP]:port
if strings.HasPrefix(addr, "[") {
// IPv6 format: [::1]:8080
if idx := strings.LastIndex(addr, "]:"); idx != -1 {
return addr[1:idx] // Extract IP between [ and ]
}
// Malformed IPv6 address
return addr
}
// For IPv4, split on last colon
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
}
// No port found
return addr
}
// GetTrustedProxyCount returns the number of configured trusted proxy ranges
func (e *Extractor) GetTrustedProxyCount() int {
return len(e.trustedProxies)
}
// HasTrustedProxies returns true if any trusted proxies are configured
func (e *Extractor) HasTrustedProxies() bool {
return len(e.trustedProxies) > 0
}

View file

@ -0,0 +1,19 @@
package clientip
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// ProvideExtractor provides a client IP extractor configured from the application config
func ProvideExtractor(cfg *config.Config, logger *zap.Logger) (*Extractor, error) {
// If no trusted proxies configured, use default (no X-Forwarded-For trust)
if len(cfg.Security.TrustedProxies) == 0 {
logger.Info("no trusted proxies configured - X-Forwarded-For headers will be ignored for security")
return NewDefaultExtractor(logger), nil
}
// Create extractor with trusted proxies
return NewExtractor(cfg.Security.TrustedProxies, logger)
}

View file

@ -0,0 +1,127 @@
package ipcountryblocker
import (
"context"
"fmt"
"net"
"sync"
"github.com/oschwald/geoip2-golang"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// Provider defines the interface for IP-based country blocking operations.
// It provides methods to check if an IP or country is blocked and to retrieve
// country codes for given IP addresses.
type Provider interface {
// IsBlockedCountry checks if a country is in the blocked list.
// isoCode must be an ISO 3166-1 alpha-2 country code.
IsBlockedCountry(isoCode string) bool
// IsBlockedIP determines if an IP address originates from a blocked country.
// Returns false for nil IP addresses or if country lookup fails.
IsBlockedIP(ctx context.Context, ip net.IP) bool
// GetCountryCode returns the ISO 3166-1 alpha-2 country code for an IP address.
// Returns an error if the lookup fails or no country is found.
GetCountryCode(ctx context.Context, ip net.IP) (string, error)
// Close releases resources associated with the provider.
Close() error
}
// provider implements the Provider interface using MaxMind's GeoIP2 database.
type provider struct {
db *geoip2.Reader
blockedCountries map[string]struct{} // Uses empty struct to optimize memory
logger *zap.Logger
mu sync.RWMutex // Protects concurrent access to blockedCountries
}
// NewProvider creates a new IP country blocking provider using the provided configuration.
// It initializes the GeoIP2 database and sets up the blocked countries list.
// Fatally crashes the entire application if the database cannot be opened.
func NewProvider(cfg *config.Config, logger *zap.Logger) Provider {
logger.Info("⏳ Loading GeoIP2 database...",
zap.String("db_path", cfg.App.GeoLiteDBPath))
db, err := geoip2.Open(cfg.App.GeoLiteDBPath)
if err != nil {
logger.Fatal("Failed to open GeoLite2 database",
zap.String("db_path", cfg.App.GeoLiteDBPath),
zap.Error(err))
}
blocked := make(map[string]struct{}, len(cfg.App.BannedCountries))
for _, country := range cfg.App.BannedCountries {
blocked[country] = struct{}{}
}
logger.Info("✓ IP country blocker initialized",
zap.Int("blocked_countries", len(cfg.App.BannedCountries)))
return &provider{
db: db,
blockedCountries: blocked,
logger: logger,
}
}
// IsBlockedCountry checks if a country code exists in the blocked countries map.
// Thread-safe through RLock.
func (p *provider) IsBlockedCountry(isoCode string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
_, exists := p.blockedCountries[isoCode]
return exists
}
// IsBlockedIP performs a country lookup for the IP and checks if it's blocked.
// Returns false for nil IPs or failed lookups to fail safely.
func (p *provider) IsBlockedIP(ctx context.Context, ip net.IP) bool {
if ip == nil {
return false
}
code, err := p.GetCountryCode(ctx, ip)
if err != nil {
// Developers Note:
// Comment this console log as it contributes a `noisy` server log.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// p.logger.WarnContext(ctx, "failed to get country code",
// zap.Any("ip", ip),
// zap.Any("error", err))
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Developers Note:
// If the country d.n.e. exist that means we will return with `false`
// indicating this IP address is allowed to access our server. If this
// is concerning then you might set this to `true` to block on all
// IP address which are not categorized by country.
return false
}
return p.IsBlockedCountry(code)
}
// GetCountryCode performs a GeoIP2 database lookup to determine an IP's country.
// Returns an error if the lookup fails or no country is found.
func (p *provider) GetCountryCode(ctx context.Context, ip net.IP) (string, error) {
record, err := p.db.Country(ip)
if err != nil {
return "", fmt.Errorf("lookup country: %w", err)
}
if record == nil || record.Country.IsoCode == "" {
return "", fmt.Errorf("no country found for IP: %v", ip)
}
return record.Country.IsoCode, nil
}
// Close cleanly shuts down the GeoIP2 database connection.
func (p *provider) Close() error {
return p.db.Close()
}

View file

@ -0,0 +1,12 @@
package ipcountryblocker
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// ProvideIPCountryBlocker creates a new IP country blocker provider instance.
func ProvideIPCountryBlocker(cfg *config.Config, logger *zap.Logger) Provider {
return NewProvider(cfg, logger)
}

View file

@ -0,0 +1,221 @@
package ipcrypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"net"
"time"
"go.uber.org/zap"
)
// IPEncryptor provides secure IP address encryption for GDPR compliance
// Uses AES-GCM (Galois/Counter Mode) for authenticated encryption
// Encrypts IP addresses before storage and provides expiration checking
type IPEncryptor struct {
gcm cipher.AEAD
logger *zap.Logger
}
// NewIPEncryptor creates a new IP encryptor with the given encryption key
// keyHex should be a 32-character hex string (16 bytes for AES-128)
// or 64-character hex string (32 bytes for AES-256)
// Example: "0123456789abcdef0123456789abcdef" (AES-128)
// Recommended: Use AES-256 with 64-character hex key
func NewIPEncryptor(keyHex string, logger *zap.Logger) (*IPEncryptor, error) {
// Decode hex key to bytes
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
return nil, fmt.Errorf("invalid hex key: %w", err)
}
// AES requires exactly 16, 24, or 32 bytes
if len(keyBytes) != 16 && len(keyBytes) != 24 && len(keyBytes) != 32 {
return nil, fmt.Errorf("key must be 16, 24, or 32 bytes (32, 48, or 64 hex characters), got %d bytes", len(keyBytes))
}
// Create AES cipher block
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM (Galois/Counter Mode) for authenticated encryption
// GCM provides both confidentiality and integrity
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
logger.Info("IP encryptor initialized with AES-GCM",
zap.Int("key_length_bytes", len(keyBytes)),
zap.Int("nonce_size", gcm.NonceSize()),
zap.Int("overhead", gcm.Overhead()))
return &IPEncryptor{
gcm: gcm,
logger: logger.Named("ip-encryptor"),
}, nil
}
// Encrypt encrypts an IP address for secure storage using AES-GCM
// Returns base64-encoded encrypted IP address with embedded nonce
// Format: base64(nonce + ciphertext + auth_tag)
// Supports both IPv4 and IPv6 addresses
//
// Security Properties:
// - Semantic security: same IP address produces different ciphertext each time
// - Authentication: tampering with ciphertext is detected
// - Unique nonce per encryption prevents pattern analysis
func (e *IPEncryptor) Encrypt(ipAddress string) (string, error) {
if ipAddress == "" {
return "", nil // Empty string remains empty
}
// Parse IP address to validate format
ip := net.ParseIP(ipAddress)
if ip == nil {
e.logger.Warn("invalid IP address format",
zap.String("ip", ipAddress))
return "", fmt.Errorf("invalid IP address: %s", ipAddress)
}
// Convert to 16-byte representation (IPv4 gets converted to IPv6 format)
ipBytes := ip.To16()
if ipBytes == nil {
return "", fmt.Errorf("failed to convert IP to 16-byte format")
}
// Generate a random nonce (number used once)
// GCM requires a unique nonce for each encryption operation
nonce := make([]byte, e.gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
e.logger.Error("failed to generate nonce", zap.Error(err))
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt the IP bytes using AES-GCM
// GCM appends the authentication tag to the ciphertext
// nil additional data means no associated data
ciphertext := e.gcm.Seal(nil, nonce, ipBytes, nil)
// Prepend nonce to ciphertext for storage
// Format: nonce || ciphertext+tag
encryptedData := append(nonce, ciphertext...)
// Encode to base64 for database storage (text-safe)
encryptedBase64 := base64.StdEncoding.EncodeToString(encryptedData)
e.logger.Debug("IP address encrypted with AES-GCM",
zap.Int("plaintext_length", len(ipBytes)),
zap.Int("nonce_length", len(nonce)),
zap.Int("ciphertext_length", len(ciphertext)),
zap.Int("total_encrypted_length", len(encryptedData)),
zap.Int("base64_length", len(encryptedBase64)))
return encryptedBase64, nil
}
// Decrypt decrypts an encrypted IP address
// Takes base64-encoded encrypted IP and returns original IP address string
// Verifies authentication tag to detect tampering
func (e *IPEncryptor) Decrypt(encryptedBase64 string) (string, error) {
if encryptedBase64 == "" {
return "", nil // Empty string remains empty
}
// Decode base64 to bytes
encryptedData, err := base64.StdEncoding.DecodeString(encryptedBase64)
if err != nil {
e.logger.Warn("invalid base64-encoded encrypted IP",
zap.String("base64", encryptedBase64),
zap.Error(err))
return "", fmt.Errorf("invalid base64 encoding: %w", err)
}
// Extract nonce from the beginning
nonceSize := e.gcm.NonceSize()
if len(encryptedData) < nonceSize {
return "", fmt.Errorf("encrypted data too short: expected at least %d bytes, got %d", nonceSize, len(encryptedData))
}
nonce := encryptedData[:nonceSize]
ciphertext := encryptedData[nonceSize:]
// Decrypt and verify authentication tag using AES-GCM
ipBytes, err := e.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
e.logger.Warn("failed to decrypt IP address (authentication failed or corrupted data)",
zap.Error(err))
return "", fmt.Errorf("decryption failed: %w", err)
}
// Convert bytes to IP address
ip := net.IP(ipBytes)
if ip == nil {
return "", fmt.Errorf("failed to parse decrypted IP bytes")
}
// Convert to string
ipString := ip.String()
e.logger.Debug("IP address decrypted with AES-GCM",
zap.Int("encrypted_length", len(encryptedData)),
zap.Int("decrypted_length", len(ipBytes)))
return ipString, nil
}
// IsExpired checks if an IP address timestamp has expired (> 90 days old)
// GDPR compliance: IP addresses must be deleted after 90 days
func (e *IPEncryptor) IsExpired(timestamp time.Time) bool {
if timestamp.IsZero() {
return false // No timestamp means not expired (will be cleaned up later)
}
// Calculate age in days
age := time.Since(timestamp)
ageInDays := int(age.Hours() / 24)
expired := ageInDays > 90
if expired {
e.logger.Debug("IP timestamp expired",
zap.Time("timestamp", timestamp),
zap.Int("age_days", ageInDays))
}
return expired
}
// ShouldCleanup checks if an IP address should be cleaned up based on timestamp
// Returns true if timestamp is older than 90 days OR if timestamp is zero (unset)
func (e *IPEncryptor) ShouldCleanup(timestamp time.Time) bool {
// Always cleanup if timestamp is not set (backwards compatibility)
if timestamp.IsZero() {
return false // Don't cleanup unset timestamps immediately
}
return e.IsExpired(timestamp)
}
// ValidateKey validates that a key is properly formatted for IP encryption
// Returns true if key is valid 32-character hex string (AES-128) or 64-character (AES-256)
func ValidateKey(keyHex string) error {
// Check length (must be 16, 24, or 32 bytes = 32, 48, or 64 hex chars)
if len(keyHex) != 32 && len(keyHex) != 48 && len(keyHex) != 64 {
return fmt.Errorf("key must be 32, 48, or 64 hex characters, got %d characters", len(keyHex))
}
// Check if valid hex
_, err := hex.DecodeString(keyHex)
if err != nil {
return fmt.Errorf("key must be valid hex string: %w", err)
}
return nil
}

View file

@ -0,0 +1,13 @@
package ipcrypt
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// ProvideIPEncryptor provides an IP encryptor instance
// CWE-359: GDPR compliance for IP address storage
func ProvideIPEncryptor(cfg *config.Config, logger *zap.Logger) (*IPEncryptor, error) {
return NewIPEncryptor(cfg.Security.IPEncryptionKey, logger)
}

View file

@ -0,0 +1,110 @@
package jwt
import (
"fmt"
"log"
"time"
"github.com/golang-jwt/jwt/v5"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/validator"
)
// Provider provides interface for JWT token generation and validation
type Provider interface {
GenerateToken(sessionID string, duration time.Duration) (string, time.Time, error)
GenerateTokenPair(sessionID string, accessDuration time.Duration, refreshDuration time.Duration) (accessToken string, accessExpiry time.Time, refreshToken string, refreshExpiry time.Time, err error)
ValidateToken(tokenString string) (sessionID string, err error)
}
type provider struct {
secret []byte
}
// NewProvider creates a new JWT provider with security validation
func NewProvider(cfg *config.Config) Provider {
// Validate JWT secret security before creating provider
v := validator.NewCredentialValidator()
if err := v.ValidateJWTSecret(cfg.App.JWTSecret, cfg.App.Environment); err != nil {
// Log detailed error with remediation steps
log.Printf("[SECURITY ERROR] %s", err.Error())
// In production, this is a fatal error that should prevent startup
if cfg.App.Environment == "production" {
panic(fmt.Sprintf("SECURITY: Invalid JWT secret in production environment: %s", err.Error()))
}
// In development, log warning but allow to continue
log.Printf("[WARNING] Continuing with weak JWT secret in %s environment. This is NOT safe for production!", cfg.App.Environment)
}
return &provider{
secret: []byte(cfg.App.JWTSecret),
}
}
// GenerateToken generates a single JWT token
func (p *provider) GenerateToken(sessionID string, duration time.Duration) (string, time.Time, error) {
expiresAt := time.Now().Add(duration)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"session_id": sessionID,
"exp": expiresAt.Unix(),
})
tokenString, err := token.SignedString(p.secret)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to sign token: %w", err)
}
return tokenString, expiresAt, nil
}
// GenerateTokenPair generates both access token and refresh token
func (p *provider) GenerateTokenPair(sessionID string, accessDuration time.Duration, refreshDuration time.Duration) (string, time.Time, string, time.Time, error) {
// Generate access token
accessToken, accessExpiry, err := p.GenerateToken(sessionID, accessDuration)
if err != nil {
return "", time.Time{}, "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err)
}
// Generate refresh token
refreshToken, refreshExpiry, err := p.GenerateToken(sessionID, refreshDuration)
if err != nil {
return "", time.Time{}, "", time.Time{}, fmt.Errorf("failed to generate refresh token: %w", err)
}
return accessToken, accessExpiry, refreshToken, refreshExpiry, nil
}
// ValidateToken validates a JWT token and returns the session ID
func (p *provider) ValidateToken(tokenString string) (string, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return p.secret, nil
})
if err != nil {
return "", fmt.Errorf("failed to parse token: %w", err)
}
if !token.Valid {
return "", fmt.Errorf("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", fmt.Errorf("invalid token claims")
}
sessionID, ok := claims["session_id"].(string)
if !ok {
return "", fmt.Errorf("session_id not found in token")
}
return sessionID, nil
}

View file

@ -0,0 +1,10 @@
package jwt
import (
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// ProvideProvider provides a JWT provider instance for Wire dependency injection
func ProvideProvider(cfg *config.Config) Provider {
return NewProvider(cfg)
}

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
}

View file

@ -0,0 +1,20 @@
package security
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
)
// ProvideJWTProvider provides a JWT provider instance
func ProvideJWTProvider(cfg *config.Config) jwt.Provider {
return jwt.NewProvider(cfg)
}
// ProvideClientIPExtractor provides a client IP extractor instance
// CWE-348: Secure IP extraction with X-Forwarded-For validation
func ProvideClientIPExtractor(cfg *config.Config, logger *zap.Logger) (*clientip.Extractor, error) {
return clientip.ProvideExtractor(cfg, logger)
}

View file

@ -0,0 +1,49 @@
package securebytes
import (
"errors"
"github.com/awnumar/memguard"
)
// SecureBytes is used to store a byte slice securely in memory.
// It uses memguard to protect sensitive data from being exposed in memory dumps,
// swap files, or other memory scanning attacks.
type SecureBytes struct {
buffer *memguard.LockedBuffer
}
// NewSecureBytes creates a new SecureBytes instance from the given byte slice.
// The original byte slice should be wiped after creating SecureBytes to ensure
// the sensitive data is only stored in the secure buffer.
func NewSecureBytes(b []byte) (*SecureBytes, error) {
if len(b) == 0 {
return nil, errors.New("byte slice cannot be empty")
}
buffer := memguard.NewBuffer(len(b))
// Check if buffer was created successfully
if buffer == nil {
return nil, errors.New("failed to create buffer")
}
copy(buffer.Bytes(), b)
return &SecureBytes{buffer: buffer}, nil
}
// Bytes returns the securely stored byte slice.
// WARNING: The returned bytes are still protected by memguard, but any copies
// made from this slice will not be protected. Use with caution.
func (sb *SecureBytes) Bytes() []byte {
return sb.buffer.Bytes()
}
// Wipe removes the byte slice from memory and makes it unrecoverable.
// After calling Wipe, the SecureBytes instance should not be used.
func (sb *SecureBytes) Wipe() error {
sb.buffer.Wipe()
sb.buffer = nil
return nil
}

View file

@ -0,0 +1,71 @@
package securestring
import (
"errors"
"github.com/awnumar/memguard"
)
// SecureString is used to store a string securely in memory.
// It uses memguard to protect sensitive data like passwords, API keys, etc.
// from being exposed in memory dumps, swap files, or other memory scanning attacks.
type SecureString struct {
buffer *memguard.LockedBuffer
}
// NewSecureString creates a new SecureString instance from the given string.
// The original string should be cleared/wiped after creating SecureString to ensure
// the sensitive data is only stored in the secure buffer.
func NewSecureString(s string) (*SecureString, error) {
if len(s) == 0 {
return nil, errors.New("string cannot be empty")
}
// Use memguard's built-in method for creating from bytes
buffer := memguard.NewBufferFromBytes([]byte(s))
// Check if buffer was created successfully
if buffer == nil {
return nil, errors.New("failed to create buffer")
}
return &SecureString{buffer: buffer}, nil
}
// String returns the securely stored string.
// WARNING: The returned string is a copy and will not be protected by memguard.
// Use this method carefully and wipe the string after use if possible.
func (ss *SecureString) String() string {
if ss.buffer == nil {
return ""
}
if !ss.buffer.IsAlive() {
return ""
}
return ss.buffer.String()
}
// Bytes returns the byte representation of the securely stored string.
// WARNING: The returned bytes are still protected by memguard, but any copies
// made from this slice will not be protected. Use with caution.
func (ss *SecureString) Bytes() []byte {
if ss.buffer == nil {
return nil
}
if !ss.buffer.IsAlive() {
return nil
}
return ss.buffer.Bytes()
}
// Wipe removes the string from memory and makes it unrecoverable.
// After calling Wipe, the SecureString instance should not be used.
func (ss *SecureString) Wipe() error {
if ss.buffer != nil {
if ss.buffer.IsAlive() {
ss.buffer.Destroy()
}
}
ss.buffer = nil
return nil
}

View file

@ -0,0 +1,435 @@
package validator
import (
"fmt"
"math"
"strings"
"unicode"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
const (
// MinJWTSecretLength is the minimum required length for JWT secrets (256 bits)
MinJWTSecretLength = 32
// RecommendedJWTSecretLength is the recommended length for JWT secrets (512 bits)
RecommendedJWTSecretLength = 64
// MinEntropyBits is the minimum Shannon entropy in bits per character
// For reference: random base64 has ~6 bits/char, we require minimum 4.0
MinEntropyBits = 4.0
// MinProductionEntropyBits is the minimum entropy required for production
MinProductionEntropyBits = 4.5
// MaxRepeatingCharacters is the maximum allowed consecutive repeating characters
MaxRepeatingCharacters = 3
)
// WeakSecrets contains common weak/default secrets that should never be used
var WeakSecrets = []string{
"secret",
"password",
"changeme",
"change-me",
"change_me",
"12345",
"123456",
"1234567",
"12345678",
"123456789",
"1234567890",
"default",
"test",
"testing",
"admin",
"administrator",
"root",
"qwerty",
"qwertyuiop",
"letmein",
"welcome",
"monkey",
"dragon",
"master",
"sunshine",
"princess",
"football",
"starwars",
"baseball",
"superman",
"iloveyou",
"trustno1",
"hello",
"abc123",
"password123",
"admin123",
"guest",
"user",
"demo",
"sample",
"example",
}
// DangerousPatterns contains patterns that indicate a secret should be changed
var DangerousPatterns = []string{
"change",
"replace",
"update",
"modify",
"sample",
"example",
"todo",
"fixme",
"temp",
"temporary",
}
// CredentialValidator validates credentials and secrets for security issues
type CredentialValidator interface {
ValidateJWTSecret(secret string, environment string) error
ValidateAllCredentials(cfg *config.Config) error
}
type credentialValidator struct{}
// NewCredentialValidator creates a new credential validator
func NewCredentialValidator() CredentialValidator {
return &credentialValidator{}
}
// ValidateJWTSecret validates JWT secret strength and security
// CWE-798: Comprehensive validation to prevent hard-coded/weak credentials
func (v *credentialValidator) ValidateJWTSecret(secret string, environment string) error {
// Check minimum length
if len(secret) < MinJWTSecretLength {
return fmt.Errorf(
"JWT secret is too short (%d characters). Minimum required: %d characters (256 bits). "+
"Generate a secure secret with: openssl rand -base64 64",
len(secret),
MinJWTSecretLength,
)
}
// Check for common weak secrets (case-insensitive)
secretLower := strings.ToLower(secret)
for _, weak := range WeakSecrets {
if secretLower == weak || strings.Contains(secretLower, weak) {
return fmt.Errorf(
"JWT secret cannot contain common weak value: '%s'. "+
"Generate a secure secret with: openssl rand -base64 64",
weak,
)
}
}
// Check for dangerous patterns indicating default/placeholder values
for _, pattern := range DangerousPatterns {
if strings.Contains(secretLower, pattern) {
return fmt.Errorf(
"JWT secret contains suspicious pattern '%s' which suggests it's a placeholder. "+
"Generate a secure secret with: openssl rand -base64 64",
pattern,
)
}
}
// Check for repeating character patterns (e.g., "aaaa", "1111")
if err := checkRepeatingPatterns(secret); err != nil {
return fmt.Errorf(
"JWT secret validation failed: %s. "+
"Generate a secure secret with: openssl rand -base64 64",
err.Error(),
)
}
// Check for sequential patterns (e.g., "abcd", "1234")
if hasSequentialPattern(secret) {
return fmt.Errorf(
"JWT secret contains sequential patterns (e.g., 'abcd', '1234') which reduces entropy. "+
"Generate a secure secret with: openssl rand -base64 64",
)
}
// Calculate Shannon entropy
entropy := calculateShannonEntropy(secret)
minEntropy := MinEntropyBits
if environment == "production" {
minEntropy = MinProductionEntropyBits
}
if entropy < minEntropy {
return fmt.Errorf(
"JWT secret has insufficient entropy: %.2f bits/char (minimum: %.1f bits/char for %s). "+
"The secret appears to have low randomness. "+
"Generate a secure secret with: openssl rand -base64 64",
entropy,
minEntropy,
environment,
)
}
// In production, enforce stricter requirements
if environment == "production" {
// Check recommended length for production
if len(secret) < RecommendedJWTSecretLength {
return fmt.Errorf(
"JWT secret is too short for production environment (%d characters). "+
"Recommended: %d characters (512 bits). "+
"Generate a secure secret with: openssl rand -base64 64",
len(secret),
RecommendedJWTSecretLength,
)
}
// Check for sufficient character complexity
if !hasSufficientComplexity(secret) {
return fmt.Errorf(
"JWT secret has insufficient complexity for production. It should contain a mix of uppercase, lowercase, " +
"digits, and special characters (at least 3 types). Generate a secure secret with: openssl rand -base64 64",
)
}
// Validate base64-like characteristics (recommended generation method)
if !looksLikeBase64(secret) {
return fmt.Errorf(
"JWT secret does not appear to be randomly generated (expected base64-like characteristics). "+
"Generate a secure secret with: openssl rand -base64 64",
)
}
}
return nil
}
// ValidateAllCredentials validates all credentials in the configuration
func (v *credentialValidator) ValidateAllCredentials(cfg *config.Config) error {
var errors []string
// Validate JWT Secret
if err := v.ValidateJWTSecret(cfg.App.JWTSecret, cfg.App.Environment); err != nil {
errors = append(errors, fmt.Sprintf("JWT Secret validation failed: %s", err.Error()))
}
// In production, ensure other critical configs are not using defaults/placeholders
if cfg.App.Environment == "production" {
// Check Meilisearch API key
if cfg.Meilisearch.APIKey == "" {
errors = append(errors, "Meilisearch API key must be set in production")
} else if containsDangerousPattern(cfg.Meilisearch.APIKey) {
errors = append(errors, "Meilisearch API key appears to be a placeholder/default value")
}
// Check database hosts are not using localhost
for _, host := range cfg.Database.Hosts {
if strings.Contains(strings.ToLower(host), "localhost") || host == "127.0.0.1" {
errors = append(errors, "Database hosts should not use localhost in production")
break
}
}
// Check cache host is not localhost
if strings.Contains(strings.ToLower(cfg.Cache.Host), "localhost") || cfg.Cache.Host == "127.0.0.1" {
errors = append(errors, "Cache host should not use localhost in production")
}
}
if len(errors) > 0 {
return fmt.Errorf("credential validation failed:\n - %s", strings.Join(errors, "\n - "))
}
return nil
}
// calculateShannonEntropy calculates the Shannon entropy of a string in bits per character
// Shannon entropy measures the randomness/unpredictability of data
// Formula: H(X) = -Σ(p(x) * log2(p(x))) where p(x) is the probability of character x
func calculateShannonEntropy(s string) float64 {
if len(s) == 0 {
return 0
}
// Count character frequencies
frequencies := make(map[rune]int)
for _, char := range s {
frequencies[char]++
}
// Calculate entropy
var entropy float64
length := float64(len(s))
for _, count := range frequencies {
probability := float64(count) / length
entropy -= probability * math.Log2(probability)
}
return entropy
}
// hasSufficientComplexity checks if the secret has a good mix of character types
// Requires at least 3 out of 4 character types for production
func hasSufficientComplexity(secret string) bool {
var (
hasUpper bool
hasLower bool
hasDigit bool
hasSpecial bool
)
for _, char := range secret {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsDigit(char):
hasDigit = true
default:
hasSpecial = true
}
}
// Require at least 3 out of 4 character types
count := 0
if hasUpper {
count++
}
if hasLower {
count++
}
if hasDigit {
count++
}
if hasSpecial {
count++
}
return count >= 3
}
// checkRepeatingPatterns checks for excessive repeating characters
func checkRepeatingPatterns(s string) error {
if len(s) < 2 {
return nil
}
repeatCount := 1
lastChar := rune(s[0])
for _, char := range s[1:] {
if char == lastChar {
repeatCount++
if repeatCount > MaxRepeatingCharacters {
return fmt.Errorf(
"contains %d consecutive repeating characters ('%c'), maximum allowed: %d",
repeatCount,
lastChar,
MaxRepeatingCharacters,
)
}
} else {
repeatCount = 1
lastChar = char
}
}
return nil
}
// hasSequentialPattern detects common sequential patterns
func hasSequentialPattern(s string) bool {
if len(s) < 4 {
return false
}
// Check for at least 4 consecutive sequential characters
for i := 0; i < len(s)-3; i++ {
// Check ascending sequence (e.g., "abcd", "1234")
if s[i+1] == s[i]+1 && s[i+2] == s[i]+2 && s[i+3] == s[i]+3 {
return true
}
// Check descending sequence (e.g., "dcba", "4321")
if s[i+1] == s[i]-1 && s[i+2] == s[i]-2 && s[i+3] == s[i]-3 {
return true
}
}
return false
}
// looksLikeBase64 checks if the string has base64-like characteristics
// Base64 uses: A-Z, a-z, 0-9, +, /, and = for padding
func looksLikeBase64(s string) bool {
if len(s) < MinJWTSecretLength {
return false
}
var (
hasUpper bool
hasLower bool
hasDigit bool
validChars int
)
// Base64 valid characters
for _, char := range s {
switch {
case char >= 'A' && char <= 'Z':
hasUpper = true
validChars++
case char >= 'a' && char <= 'z':
hasLower = true
validChars++
case char >= '0' && char <= '9':
hasDigit = true
validChars++
case char == '+' || char == '/' || char == '=' || char == '-' || char == '_':
validChars++
default:
// Invalid character for base64
return false
}
}
// Should have good mix of character types typical of base64
charTypesCount := 0
if hasUpper {
charTypesCount++
}
if hasLower {
charTypesCount++
}
if hasDigit {
charTypesCount++
}
// Base64 typically has at least uppercase, lowercase, and digits
// Also check that it doesn't look like a repeated pattern
if charTypesCount < 3 {
return false
}
// Check for repeated patterns (e.g., "AbCd12!@" repeated)
// If the string has low unique character count relative to its length, it's probably not random
uniqueChars := make(map[rune]bool)
for _, char := range s {
uniqueChars[char] = true
}
// Random base64 should have at least 50% unique characters for strings over 32 chars
uniqueRatio := float64(len(uniqueChars)) / float64(len(s))
return uniqueRatio >= 0.4 // At least 40% unique characters
}
// containsDangerousPattern checks if a string contains any dangerous patterns
func containsDangerousPattern(value string) bool {
valueLower := strings.ToLower(value)
for _, pattern := range DangerousPatterns {
if strings.Contains(valueLower, pattern) {
return true
}
}
return false
}

View file

@ -0,0 +1,113 @@
package validator
import (
"testing"
)
// Simplified comprehensive test for JWT secret validation
func TestJWTSecretValidation(t *testing.T) {
validator := NewCredentialValidator()
// Good secrets - these should pass
goodSecrets := []struct {
name string
secret string
env string
}{
{
name: "Good 32-char for dev",
secret: "ima7xR+9nT0Yz0jKVu/QwtkqdAaU+3Ki",
env: "development",
},
{
name: "Good 64-char for prod",
secret: "1WDduocStecRuIv+Us1t/RnYDoW1ZcEEbU+H+WykJG+IT5WnijzBb8uUPzGKju+D",
env: "production",
},
}
for _, tt := range goodSecrets {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateJWTSecret(tt.secret, tt.env)
if err != nil {
t.Errorf("Expected no error for valid secret, got: %v", err)
}
})
}
// Bad secrets - these should fail
badSecrets := []struct {
name string
secret string
env string
mustContain string
}{
{
name: "Too short",
secret: "short",
env: "development",
mustContain: "too short",
},
{
name: "Common weak - password",
secret: "password-is-not-secure-but-32char",
env: "development",
mustContain: "common weak value",
},
{
name: "Dangerous pattern",
secret: "please-change-this-ima7xR+9nT0Yz",
env: "development",
mustContain: "suspicious pattern",
},
{
name: "Repeating characters",
secret: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
env: "development",
mustContain: "consecutive repeating characters",
},
{
name: "Sequential pattern",
secret: "abcdefghijklmnopqrstuvwxyzabcdef",
env: "development",
mustContain: "sequential patterns",
},
{
name: "Low entropy",
secret: "abababababababababababababababab",
env: "development",
mustContain: "insufficient entropy",
},
{
name: "Prod too short",
secret: "ima7xR+9nT0Yz0jKVu/QwtkqdAaU+3Ki",
env: "production",
mustContain: "too short for production",
},
}
for _, tt := range badSecrets {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateJWTSecret(tt.secret, tt.env)
if err == nil {
t.Errorf("Expected error containing '%s', got no error", tt.mustContain)
} else if !contains(err.Error(), tt.mustContain) {
t.Errorf("Expected error containing '%s', got: %v", tt.mustContain, err)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View file

@ -0,0 +1,535 @@
package validator
import (
"strings"
"testing"
)
func TestCalculateShannonEntropy(t *testing.T) {
tests := []struct {
name string
input string
minBits float64
maxBits float64
expected string
}{
{
name: "Empty string",
input: "",
minBits: 0,
maxBits: 0,
expected: "should have 0 entropy",
},
{
name: "All same character",
input: "aaaaaaaaaa",
minBits: 0,
maxBits: 0,
expected: "should have very low entropy",
},
{
name: "Low entropy - repeated pattern",
input: "abcabcabcabc",
minBits: 1.5,
maxBits: 2.0,
expected: "should have low entropy",
},
{
name: "Medium entropy - simple password",
input: "Password123",
minBits: 3.0,
maxBits: 4.5,
expected: "should have medium entropy",
},
{
name: "High entropy - random base64",
input: "j8EJm9/ZKnuTYxcVKQK/NWcrt1Drgzx",
minBits: 4.0,
maxBits: 6.0,
expected: "should have high entropy",
},
{
name: "Very high entropy - long random base64",
input: "PKiQCYBT+AxkksUbC+F5NJsQBG+GDRvlc/5d+240xljW2uVtzsz0uqv0sjCJFirR",
minBits: 4.5,
maxBits: 6.5,
expected: "should have very high entropy",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entropy := calculateShannonEntropy(tt.input)
if entropy < tt.minBits || entropy > tt.maxBits {
t.Errorf("%s: got %.2f bits/char, expected between %.1f and %.1f", tt.expected, entropy, tt.minBits, tt.maxBits)
}
})
}
}
func TestHasSufficientComplexity(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "Empty string",
input: "",
expected: false,
},
{
name: "Only lowercase",
input: "abcdefghijklmnop",
expected: false,
},
{
name: "Only uppercase",
input: "ABCDEFGHIJKLMNOP",
expected: false,
},
{
name: "Only digits",
input: "1234567890",
expected: false,
},
{
name: "Lowercase + uppercase",
input: "AbCdEfGhIjKl",
expected: false,
},
{
name: "Lowercase + digits",
input: "abc123def456",
expected: false,
},
{
name: "Uppercase + digits",
input: "ABC123DEF456",
expected: false,
},
{
name: "Lowercase + uppercase + digits",
input: "Abc123Def456",
expected: true,
},
{
name: "Lowercase + uppercase + special",
input: "AbC+DeF/GhI=",
expected: true,
},
{
name: "Lowercase + digits + special",
input: "abc123+def456/",
expected: true,
},
{
name: "All four types",
input: "Abc123+Def456/",
expected: true,
},
{
name: "Base64 string",
input: "K8vN2mP9sQ4tR7wY3zA6b+xK8vN2mP9sQ4tR7wY3zA6b=",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasSufficientComplexity(tt.input)
if result != tt.expected {
t.Errorf("hasSufficientComplexity(%q) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestCheckRepeatingPatterns(t *testing.T) {
tests := []struct {
name string
input string
shouldErr bool
}{
{
name: "Empty string",
input: "",
shouldErr: false,
},
{
name: "Single character",
input: "a",
shouldErr: false,
},
{
name: "No repeating",
input: "abcdefgh",
shouldErr: false,
},
{
name: "Two repeating (ok)",
input: "aabcdeef",
shouldErr: false,
},
{
name: "Three repeating (ok)",
input: "aaabcdeee",
shouldErr: false,
},
{
name: "Four repeating (error)",
input: "aaaabcde",
shouldErr: true,
},
{
name: "Five repeating (error)",
input: "aaaaabcde",
shouldErr: true,
},
{
name: "Multiple groups of three (ok)",
input: "aaabbbccc",
shouldErr: false,
},
{
name: "Repeating in middle (error)",
input: "abcdddddef",
shouldErr: true,
},
{
name: "Repeating at end (error)",
input: "abcdefgggg",
shouldErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkRepeatingPatterns(tt.input)
if (err != nil) != tt.shouldErr {
t.Errorf("checkRepeatingPatterns(%q) error = %v, shouldErr = %v", tt.input, err, tt.shouldErr)
}
})
}
}
func TestHasSequentialPattern(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "Empty string",
input: "",
expected: false,
},
{
name: "Too short",
input: "abc",
expected: false,
},
{
name: "No sequential",
input: "acegikmo",
expected: false,
},
{
name: "Ascending sequence - abcd",
input: "xyzabcdefg",
expected: true,
},
{
name: "Descending sequence - dcba",
input: "xyzdcbafg",
expected: true,
},
{
name: "Ascending digits - 1234",
input: "abc1234def",
expected: true,
},
{
name: "Descending digits - 4321",
input: "abc4321def",
expected: true,
},
{
name: "Random characters",
input: "xK8vN2mP9sQ4",
expected: false,
},
{
name: "Base64-like",
input: "K8vN2mP9sQ4tR7wY3zA6b",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasSequentialPattern(tt.input)
if result != tt.expected {
t.Errorf("hasSequentialPattern(%q) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestLooksLikeBase64(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "Empty string",
input: "",
expected: false,
},
{
name: "Too short",
input: "abc",
expected: false,
},
{
name: "Only lowercase",
input: "abcdefghijklmnopqrstuvwxyzabcdef",
expected: false,
},
{
name: "Real base64",
input: "K8vN2mP9sQ4tR7wY3zA6bxK8vN2mP9sQ4tR7wY3zA6b=",
expected: true,
},
{
name: "Base64 without padding",
input: "K8vN2mP9sQ4tR7wY3zA6bxK8vN2mP9sQ4tR7wY3zA6b",
expected: true,
},
{
name: "Base64 with URL-safe chars",
input: "K8vN2mP9sQ4tR7wY3zA6bxK8vN2mP9sQ4tR7wY3zA6b-_",
expected: true,
},
{
name: "Generated secret",
input: "xK8vN2mP9sQ4tR7wY3zA6bxK8vN2mP9sQ4tR7wY3zA6bxK8vN2mP9sQ4tR7wY3zA6b",
expected: true,
},
{
name: "Simple password",
input: "Password123!Password123!Password123!",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := looksLikeBase64(tt.input)
if result != tt.expected {
t.Errorf("looksLikeBase64(%q) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestValidateJWTSecret(t *testing.T) {
validator := NewCredentialValidator()
tests := []struct {
name string
secret string
environment string
shouldErr bool
errContains string
}{
{
name: "Too short - 20 chars",
secret: "12345678901234567890",
environment: "development",
shouldErr: true,
errContains: "too short",
},
{
name: "Minimum length - 32 chars (acceptable for dev)",
secret: "j8EJm9/ZKnuTYxcVKQK/NWcrt1Drgzx",
environment: "development",
shouldErr: false,
},
{
name: "Common weak secret - contains password",
secret: "my-password-is-secure-123456789012",
environment: "development",
shouldErr: true,
errContains: "common weak value",
},
{
name: "Common weak secret - secret",
secret: "secretsecretsecretsecretsecretsec",
environment: "development",
shouldErr: true,
errContains: "common weak value",
},
{
name: "Common weak secret - contains 12345",
secret: "abcd12345efghijklmnopqrstuvwxyz",
environment: "development",
shouldErr: true,
errContains: "common weak value",
},
{
name: "Dangerous pattern - change",
secret: "please-change-this-j8EJm9ZKnuTYxcVK",
environment: "development",
shouldErr: true,
errContains: "suspicious pattern",
},
{
name: "Dangerous pattern - sample",
secret: "sample-secret-j8EJm9ZKnuTYxcVKQ",
environment: "development",
shouldErr: true,
errContains: "suspicious pattern",
},
{
name: "Repeating characters",
secret: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
environment: "development",
shouldErr: true,
errContains: "consecutive repeating characters",
},
{
name: "Sequential pattern - abcd",
secret: "abcdefghijklmnopqrstuvwxyzabcdef",
environment: "development",
shouldErr: true,
errContains: "sequential patterns",
},
{
name: "Sequential pattern - 1234",
secret: "12345678901234567890123456789012",
environment: "development",
shouldErr: true,
errContains: "sequential patterns",
},
{
name: "Low entropy secret",
secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP",
environment: "development",
shouldErr: true,
errContains: "insufficient entropy",
},
{
name: "Good secret - base64 style (dev)",
secret: "j8EJm9/ZKnuTYxcVKQK/NWcrt1Drgzx",
environment: "development",
shouldErr: false,
},
{
name: "Good secret - longer (dev)",
secret: "PKiQCYBT+AxkksUbC+F5NJsQBG+GDRvlc/5d+240xljW2uVtzsz0uqv0sjCJFirR",
environment: "development",
shouldErr: false,
},
{
name: "Production - too short (32 chars)",
secret: "j8EJm9/ZKnuTYxcVKQK/NWcrt1Drgzx",
environment: "production",
shouldErr: true,
errContains: "too short for production",
},
{
name: "Production - insufficient complexity",
secret: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz01",
environment: "production",
shouldErr: true,
errContains: "insufficient complexity",
},
{
name: "Production - low entropy pattern",
secret: strings.Repeat("AbCd12!@", 8), // 64 chars but repetitive
environment: "production",
shouldErr: true,
errContains: "insufficient entropy",
},
{
name: "Production - good secret",
secret: "PKiQCYBT+AxkksUbC+F5NJsQBG+GDRvlc/5d+240xljW2uVtzsz0uqv0sjCJFirR",
environment: "production",
shouldErr: false,
},
{
name: "Production - excellent secret with padding",
secret: "7mK2nP8sR4wT6xZ3bA5cxK7mN1oQ9uS4vY2zA6bxK7mN1oQ9uS4vY2zA6b+W0E=",
environment: "production",
shouldErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateJWTSecret(tt.secret, tt.environment)
if tt.shouldErr {
if err == nil {
t.Errorf("ValidateJWTSecret() expected error containing %q, got no error", tt.errContains)
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("ValidateJWTSecret() error = %q, should contain %q", err.Error(), tt.errContains)
}
} else {
if err != nil {
t.Errorf("ValidateJWTSecret() unexpected error: %v", err)
}
}
})
}
}
func TestValidateJWTSecret_EdgeCases(t *testing.T) {
validator := NewCredentialValidator()
t.Run("Secret with mixed weak patterns", func(t *testing.T) {
secret := "password123admin" // Contains multiple weak patterns
err := validator.ValidateJWTSecret(secret, "development")
if err == nil {
t.Error("Expected error for secret containing weak patterns, got nil")
}
})
t.Run("Secret exactly at minimum length", func(t *testing.T) {
// 32 characters exactly
secret := "j8EJm9/ZKnuTYxcVKQK/NWcrt1Drgzx"
err := validator.ValidateJWTSecret(secret, "development")
if err != nil {
t.Errorf("Expected no error for 32-char secret in development, got: %v", err)
}
})
t.Run("Secret exactly at recommended length", func(t *testing.T) {
// 64 characters exactly - using real random base64
secret := "PKiQCYBT+AxkksUbC+F5NJsQBG+GDRvlc/5d+240xljW2uVtzsz0uqv0sjCJFir"
err := validator.ValidateJWTSecret(secret, "production")
if err != nil {
t.Errorf("Expected no error for 64-char secret in production, got: %v", err)
}
})
}
// Benchmark tests to ensure validation is performant
func BenchmarkCalculateShannonEntropy(b *testing.B) {
secret := "PKiQCYBT+AxkksUbC+F5NJsQBG+GDRvlc/5d+240xljW2uVtzsz0uqv0sjCJFirR"
b.ResetTimer()
for i := 0; i < b.N; i++ {
calculateShannonEntropy(secret)
}
}
func BenchmarkValidateJWTSecret(b *testing.B) {
validator := NewCredentialValidator()
secret := "PKiQCYBT+AxkksUbC+F5NJsQBG+GDRvlc/5d+240xljW2uVtzsz0uqv0sjCJFirR"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator.ValidateJWTSecret(secret, "production")
}
}

View file

@ -0,0 +1,6 @@
package validator
// ProvideCredentialValidator provides a credential validator for dependency injection
func ProvideCredentialValidator() CredentialValidator {
return NewCredentialValidator()
}