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