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