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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue