monorepo/cloud/maplepress-backend/pkg/security/password/breachcheck.go

149 lines
4.5 KiB
Go

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