149 lines
4.5 KiB
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
|
|
}
|