package service import ( "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math" "net/http" "regexp" "strconv" "strings" "unicode" "unicode/utf8" ) const ( minPasswordLength = 12 // Entropy checks prevent short passwords anyway maxPasswordLength = 72 // Limitation of bcrypt minPasswordEntropy = 60.0 minUsernameLength = 3 maxUsernameLength = 20 hibpAPI = "https://api.pwnedpasswords.com/range" // Doesn't require an API key ) var ( usernameRegex = regexp.MustCompile("^[a-z0-9_]+$") ) func respondJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) } // Validate the given password using a hybrid approach: length (max. set due to bcrypt's input // upper limit of 72 bytes), entropy, and HIBP API. func validatePassword(password string) error { if len(password) < minPasswordLength { return fmt.Errorf("password must be at least %d characters", minPasswordLength) } if len(password) > maxPasswordLength { return fmt.Errorf("password cannot be longer than %d characters", maxPasswordLength) } // Simple entropy approximation if calcPasswordEntropy(password) < minPasswordEntropy { return fmt.Errorf("password is too weak, try adding more uppercase letters, digits, and symbols") } if compromised, _ := isPasswordCompromised(password); compromised { return errors.New("password was found in database leaks") } return nil } // Approximate given password's entropy. Notably the way the entropy is calculated is really // conservative and punishes relatively harshly if the password contains a lot of repetition // (small set of unique characters). func calcPasswordEntropy(password string) float64 { hasLower, hasUpper, hasDigit, hasSymbol := false, false, false, false uniqueChars := make(map[rune]bool) for _, c := range password { uniqueChars[c] = true switch { case unicode.IsLower(c): hasLower = true case unicode.IsUpper(c): hasUpper = true case unicode.IsDigit(c): hasDigit = true default: if !unicode.IsLetter(c) && !unicode.IsDigit(c) { hasSymbol = true // Broader symbol collection than in the frontend } } } poolSize := 0 if hasLower { poolSize += 26 } if hasUpper { poolSize += 26 } if hasDigit { poolSize += 10 } if hasSymbol { poolSize += 40 } if poolSize == 0 { return 0 } basicEntropy := float64(len(password) * int(math.Log2(float64(poolSize)))) diversityAdjustedEntropy := math.Log2(float64(poolSize)) + float64(len(password)-1)*math.Log2(float64(len(uniqueChars))) return math.Min(basicEntropy, diversityAdjustedEntropy) } // Send the first five bytes of the password's SHA-1 hash to HIBP API, then check if the rest of // the hash is present in the APi's response data (k-Anonymity model). func isPasswordCompromised(password string) (bool, error) { hash := sha1.Sum([]byte(password)) hashStr := strings.ToUpper(hex.EncodeToString(hash[:])) prefix, suffix := hashStr[:5], hashStr[5:] resp, err := http.Get(fmt.Sprintf("%s/%s", hibpAPI, prefix)) if err != nil { return false, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return false, err } return strings.Contains(strings.ToUpper(string(body)), suffix), nil } // Normalize the username by making it lowercase and trimming any leading or trailing whitespace. func normalizeUsername(username string) string { return strings.ToLower(strings.TrimSpace(username)) } // Validate the given username by making sure it only contains alphanumeric characters or // underscores and adheres the hardcoded minimum and maximum length rules. func validateUsername(username string) error { if utf8.RuneCountInString(username) < minUsernameLength { return fmt.Errorf("username must be at least %d characters", minUsernameLength) } if utf8.RuneCountInString(username) > maxUsernameLength { return fmt.Errorf("username cannot be longer than %d characters", maxUsernameLength) } if !usernameRegex.MatchString(username) { return errors.New("username can only contain numbers, letters, and underscores") } return nil } // Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to // limit of 50 and offset 0 if parameters are missing/invalid. func getPaginationParams(r *http.Request) (limit int32, offset int32) { defaultLimit := 50 defaultOffset := 0 limitStr := r.URL.Query().Get("limit") if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { defaultLimit = l } } offsetStr := r.URL.Query().Get("offset") if offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { defaultOffset = o } } return int32(defaultLimit), int32(defaultOffset) } // Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and // return the resulting hash as a string. func sha1ContentHash(title, content string) string { hashContent := title + content hash := sha1.Sum([]byte(hashContent)) hashStr := strings.ToUpper(hex.EncodeToString(hash[:])) return hashStr }