diff --git a/README.md b/README.md index a99ef0f..b4e0b46 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,10 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 МБит/с для ВК) +Для прохождения капчи вручную - `-manual-captcha`. + +Для экспериментальной автопопытки решить пазл-слайдер можно добавить флаг `-auto-captcha-slider-poc`. + ## Яндекс телемост **UPD. ТЕЛЕМОСТ ЗАКРЫЛИ** diff --git a/client/main.go b/client/main.go index 9982aa2..a5d488c 100644 --- a/client/main.go +++ b/client/main.go @@ -22,7 +22,6 @@ import ( neturl "net/url" "os" "os/signal" - "regexp" "strconv" "strings" "sync" @@ -66,6 +65,7 @@ var ( handshakeSem = make(chan struct{}, 3) isDebug bool manualCaptcha bool + autoCaptchaSliderPOC bool ) type UDPPacket struct { @@ -307,17 +307,30 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in return "", fmt.Errorf("no redirect_uri for auto-solve") } - powInput, difficulty, err := fetchPowInput(ctx, captchaErr.RedirectUri, client, profile) + bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectUri, client, profile) if err != nil { - return "", fmt.Errorf("failed to fetch PoW input: %w", err) + return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) } - log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, powInput, difficulty) + log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty) - hash := solvePoW(powInput, difficulty) + hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash) - successToken, err := callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile) + var successToken string + if autoCaptchaSliderPOC { + successToken, err = callCaptchaNotRobotWithSliderPOC( + ctx, + captchaErr.SessionToken, + hash, + streamID, + client, + profile, + bootstrap.Settings, + ) + } else { + successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile) + } if err != nil { return "", fmt.Errorf("captchaNotRobot API failed: %w", err) } @@ -326,16 +339,16 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in return successToken, nil } -func fetchPowInput(ctx context.Context, redirectUri string, client tlsclient.HttpClient, profile Profile) (string, int, error) { +func fetchCaptchaBootstrap(ctx context.Context, redirectUri string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) { parsedURL, err := neturl.Parse(redirectUri) if err != nil { - return "", 0, err + return nil, err } domain := parsedURL.Hostname() req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectUri, nil) if err != nil { - return "", 0, err + return nil, err } req.Host = domain @@ -347,7 +360,7 @@ func fetchPowInput(ctx context.Context, redirectUri string, client tlsclient.Htt resp, err := client.Do(req) if err != nil { - return "", 0, err + return nil, err } defer func(Body io.ReadCloser) { _ = Body.Close() @@ -355,26 +368,9 @@ func fetchPowInput(ctx context.Context, redirectUri string, client tlsclient.Htt body, err := io.ReadAll(resp.Body) if err != nil { - return "", 0, err - } - html := string(body) - - powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) - powInputMatch := powInputRe.FindStringSubmatch(html) - if len(powInputMatch) < 2 { - return "", 0, fmt.Errorf("powInput not found in captcha HTML") - } - powInput := powInputMatch[1] - - diffRe := regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`) - diffMatch := diffRe.FindStringSubmatch(html) - difficulty := 2 - if len(diffMatch) >= 2 { - if d, err := strconv.Atoi(diffMatch[1]); err == nil { - difficulty = d - } + return nil, err } - return powInput, difficulty, nil + return parseCaptchaBootstrapHTML(string(body)) } func solvePoW(powInput string, difficulty int) string { @@ -1679,6 +1675,7 @@ func main() { direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") debugFlag := flag.Bool("debug", false, "enable debug logging") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") + autoCaptchaSliderPOCFlag := flag.Bool("auto-captcha-slider-poc", false, "experimental: try local slider captcha solving before manual fallback") flag.Parse() if *peerAddr == "" { log.Panicf("Need peer address!") @@ -1693,6 +1690,10 @@ func main() { isDebug = *debugFlag manualCaptcha = *manualCaptchaFlag + autoCaptchaSliderPOC = *autoCaptchaSliderPOCFlag && !manualCaptcha + if *autoCaptchaSliderPOCFlag && manualCaptcha { + log.Printf("[Captcha] manual-captcha enabled, ignoring -auto-captcha-slider-poc") + } var link string var getCreds getCredsFunc diff --git a/client/slider_captcha.go b/client/slider_captcha.go new file mode 100644 index 0000000..9b1038a --- /dev/null +++ b/client/slider_captcha.go @@ -0,0 +1,949 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/color" + _ "image/jpeg" + "io" + "log" + neturl "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" +) + +const ( + captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c" + sliderCaptchaType = "slider" + defaultSliderAttempts = 4 +) + +type captchaNotRobotSession struct { + ctx context.Context + sessionToken string + hash string + streamID int + client tlsclient.HttpClient + profile Profile + browserFp string +} + +type captchaSettingsResponse struct { + ShowCaptchaType string + SettingsByType map[string]string +} + +type captchaCheckResult struct { + Status string + SuccessToken string + ShowCaptchaType string + Redirect string +} + +type sliderCaptchaContent struct { + Extension string + Image image.Image + Size int + Steps []int + Attempts int +} + +type sliderCandidate struct { + Index int + ActiveSteps []int + Score int64 +} + +type captchaBootstrap struct { + PowInput string + Difficulty int + Settings *captchaSettingsResponse +} + +func newCaptchaNotRobotSession( + ctx context.Context, + sessionToken string, + hash string, + streamID int, + client tlsclient.HttpClient, + profile Profile, +) *captchaNotRobotSession { + return &captchaNotRobotSession{ + ctx: ctx, + sessionToken: sessionToken, + hash: hash, + streamID: streamID, + client: client, + profile: profile, + browserFp: generateBrowserFp(profile), + } +} + +func (s *captchaNotRobotSession) baseValues() neturl.Values { + values := neturl.Values{} + values.Set("session_token", s.sessionToken) + values.Set("domain", "vk.com") + values.Set("adFp", "") + values.Set("access_token", "") + return values +} + +func (s *captchaNotRobotSession) request(method string, values neturl.Values) (map[string]interface{}, error) { + reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" + parsedURL, _ := neturl.Parse(reqURL) + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(s.ctx, "POST", reqURL, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, s.profile) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://id.vk.ru") + req.Header.Set("Referer", "https://id.vk.ru/") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-GPC", "1") + req.Header.Set("Priority", "u=1, i") + + httpResp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = httpResp.Body.Close() + }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) { + resp, err := s.request("captchaNotRobot.settings", s.baseValues()) + if err != nil { + return nil, fmt.Errorf("settings failed: %w", err) + } + return parseCaptchaSettingsResponse(resp) +} + +func (s *captchaNotRobotSession) requestComponentDone() error { + values := s.baseValues() + values.Set("browser_fp", s.browserFp) + values.Set("device", buildCaptchaDeviceJSON(s.profile)) + + resp, err := s.request("captchaNotRobot.componentDone", values) + if err != nil { + return fmt.Errorf("componentDone failed: %w", err) + } + + respObj, ok := resp["response"].(map[string]interface{}) + if ok { + if status, _ := respObj["status"].(string); status != "" && status != "OK" { + return fmt.Errorf("componentDone status: %s", status) + } + } + + return nil +} + +func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) { + values := s.baseValues() + values.Set("accelerometer", "[]") + values.Set("gyroscope", "[]") + values.Set("motion", "[]") + values.Set("cursor", "[]") + values.Set("taps", "[]") + values.Set("connectionRtt", "[]") + values.Set("connectionDownlink", "[]") + values.Set("browser_fp", s.browserFp) + values.Set("hash", s.hash) + values.Set("answer", base64.StdEncoding.EncodeToString([]byte("{}"))) + values.Set("debug_info", captchaDebugInfo) + + resp, err := s.request("captchaNotRobot.check", values) + if err != nil { + return nil, fmt.Errorf("check failed: %w", err) + } + return parseCaptchaCheckResult(resp) +} + +func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) { + values := s.baseValues() + if sliderSettings != "" { + values.Set("captcha_settings", sliderSettings) + } + + resp, err := s.request("captchaNotRobot.getContent", values) + if err != nil { + return nil, fmt.Errorf("getContent failed: %w", err) + } + return parseSliderCaptchaContentResponse(resp) +} + +func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) { + answer, err := encodeSliderAnswer(activeSteps) + if err != nil { + return nil, err + } + + values := s.baseValues() + values.Set("accelerometer", "[]") + values.Set("gyroscope", "[]") + values.Set("motion", "[]") + values.Set("cursor", generateSliderCursor(candidateIndex, candidateCount)) + values.Set("taps", "[]") + values.Set("connectionRtt", "[]") + values.Set("connectionDownlink", "[]") + values.Set("browser_fp", s.browserFp) + values.Set("hash", s.hash) + values.Set("answer", answer) + values.Set("debug_info", captchaDebugInfo) + + resp, err := s.request("captchaNotRobot.check", values) + if err != nil { + return nil, fmt.Errorf("check failed: %w", err) + } + return parseCaptchaCheckResult(resp) +} + +func (s *captchaNotRobotSession) requestEndSession() { + log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", s.streamID) + if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil { + log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", s.streamID, err) + } +} + +func callCaptchaNotRobotWithSliderPOC( + ctx context.Context, + sessionToken string, + hash string, + streamID int, + client tlsclient.HttpClient, + profile Profile, + initialSettings *captchaSettingsResponse, +) (string, error) { + session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile) + + log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) + settingsResp, err := session.requestSettings() + if err != nil { + return "", err + } + settingsResp = mergeCaptchaSettings(settingsResp, initialSettings) + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) + if err := session.requestComponentDone(); err != nil { + return "", err + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) + initialCheck, err := session.requestCheckboxCheck() + if err != nil { + return "", err + } + if initialCheck.Status == "OK" { + if initialCheck.SuccessToken == "" { + return "", fmt.Errorf("success_token not found") + } + session.requestEndSession() + return initialCheck.SuccessToken, nil + } + + sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType] + log.Printf( + "[STREAM %d] [Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", + streamID, + initialCheck.Status, + settingsResp.ShowCaptchaType, + initialCheck.ShowCaptchaType, + describeCaptchaTypes(settingsResp.SettingsByType), + ) + + if !hasSlider { + log.Printf( + "[STREAM %d] [Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", + streamID, + ) + } else { + log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID) + } + + sliderContent, err := session.requestSliderContent(sliderSettings) + if err != nil { + return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err) + } + + candidates, err := rankSliderCandidates(sliderContent.Image, sliderContent.Size, sliderContent.Steps) + if err != nil { + return "", err + } + + log.Printf( + "[STREAM %d] [Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", + streamID, + len(candidates), + minInt(sliderContent.Attempts, len(candidates)), + sliderContent.Attempts, + ) + + successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) { + log.Printf( + "[STREAM %d] [Captcha] Slider guess position=%d score=%d", + streamID, + candidate.Index, + candidate.Score, + ) + return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates)) + }) + if err != nil { + return "", err + } + + session.requestEndSession() + return successToken, nil +} + +func buildCaptchaDeviceJSON(profile Profile) string { + return fmt.Sprintf( + `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`, + profile.UserAgent, + ) +} + +func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) { + respObj, ok := resp["response"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid settings response: %v", resp) + } + + settings := &captchaSettingsResponse{ + SettingsByType: make(map[string]string), + } + settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string) + + rawSettings, ok := expandCaptchaSettings(respObj["captcha_settings"]) + if !ok { + return settings, nil + } + + for _, rawItem := range rawSettings { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + + captchaType, _ := item["type"].(string) + if captchaType == "" { + continue + } + + normalized, err := normalizeCaptchaSettings(item["settings"]) + if err != nil { + return nil, fmt.Errorf("invalid captcha_settings for %s: %w", captchaType, err) + } + + settings.SettingsByType[captchaType] = normalized + } + + return settings, nil +} + +func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) { + powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) + powInputMatch := powInputRe.FindStringSubmatch(html) + if len(powInputMatch) < 2 { + return nil, fmt.Errorf("powInput not found in captcha HTML") + } + + difficulty := 2 + for _, expr := range []*regexp.Regexp{ + regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`), + regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`), + } { + if match := expr.FindStringSubmatch(html); len(match) >= 2 { + if parsed, err := strconv.Atoi(match[1]); err == nil { + difficulty = parsed + break + } + } + } + + settings, err := parseCaptchaSettingsFromHTML(html) + if err != nil { + return nil, err + } + + return &captchaBootstrap{ + PowInput: powInputMatch[1], + Difficulty: difficulty, + Settings: settings, + }, nil +} + +func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) { + initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?\})\s*;\s*window\.lang`) + initMatch := initRe.FindStringSubmatch(html) + if len(initMatch) < 2 { + return &captchaSettingsResponse{SettingsByType: make(map[string]string)}, nil + } + + var initPayload struct { + Data struct { + ShowCaptchaType string `json:"show_captcha_type"` + CaptchaSettings interface{} `json:"captcha_settings"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(initMatch[1]), &initPayload); err != nil { + return nil, fmt.Errorf("parse window.init captcha data: %w", err) + } + + return parseCaptchaSettingsResponse(map[string]interface{}{ + "response": map[string]interface{}{ + "show_captcha_type": initPayload.Data.ShowCaptchaType, + "captcha_settings": initPayload.Data.CaptchaSettings, + }, + }) +} + +func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse { + if primary == nil { + return cloneCaptchaSettings(fallback) + } + if primary.SettingsByType == nil { + primary.SettingsByType = make(map[string]string) + } + if fallback == nil { + return primary + } + if primary.ShowCaptchaType == "" { + primary.ShowCaptchaType = fallback.ShowCaptchaType + } + for captchaType, settings := range fallback.SettingsByType { + if _, exists := primary.SettingsByType[captchaType]; !exists { + primary.SettingsByType[captchaType] = settings + } + } + return primary +} + +func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse { + if src == nil { + return nil + } + + cloned := &captchaSettingsResponse{ + ShowCaptchaType: src.ShowCaptchaType, + SettingsByType: make(map[string]string, len(src.SettingsByType)), + } + for captchaType, settings := range src.SettingsByType { + cloned.SettingsByType[captchaType] = settings + } + return cloned +} + +func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) { + switch value := raw.(type) { + case nil: + return nil, false + case []interface{}: + return value, true + case map[string]interface{}: + items := make([]interface{}, 0, len(value)) + for captchaType, settings := range value { + items = append(items, map[string]interface{}{ + "type": captchaType, + "settings": settings, + }) + } + return items, true + case string: + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, false + } + + var items []interface{} + if err := json.Unmarshal([]byte(trimmed), &items); err == nil { + return items, true + } + + var mapping map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &mapping); err == nil { + return expandCaptchaSettings(mapping) + } + } + + return nil, false +} + +func normalizeCaptchaSettings(raw interface{}) (string, error) { + switch value := raw.(type) { + case nil: + return "", nil + case string: + return value, nil + default: + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(data), nil + } +} + +func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) { + respObj, ok := resp["response"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid check response: %v", resp) + } + + result := &captchaCheckResult{} + result.Status, _ = respObj["status"].(string) + result.SuccessToken, _ = respObj["success_token"].(string) + result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string) + result.Redirect, _ = respObj["redirect"].(string) + if result.Status == "" { + return nil, fmt.Errorf("check status missing: %v", resp) + } + + return result, nil +} + +func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) { + respObj, ok := resp["response"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid slider content response: %v", resp) + } + + status, _ := respObj["status"].(string) + if status != "OK" { + return nil, fmt.Errorf("slider getContent status: %s", status) + } + + extension, _ := respObj["extension"].(string) + extension = strings.ToLower(extension) + if extension != "jpeg" && extension != "jpg" { + return nil, fmt.Errorf("unsupported slider image format: %s", extension) + } + + rawImage, _ := respObj["image"].(string) + if rawImage == "" { + return nil, fmt.Errorf("slider image missing") + } + + rawSteps, ok := respObj["steps"].([]interface{}) + if !ok { + return nil, fmt.Errorf("slider steps missing") + } + + steps, err := parseIntSlice(rawSteps) + if err != nil { + return nil, err + } + + size, swaps, attempts, err := parseSliderSteps(steps) + if err != nil { + return nil, err + } + + img, err := decodeSliderImage(rawImage) + if err != nil { + return nil, err + } + + return &sliderCaptchaContent{ + Extension: extension, + Image: img, + Size: size, + Steps: swaps, + Attempts: attempts, + }, nil +} + +func parseIntSlice(raw []interface{}) ([]int, error) { + values := make([]int, 0, len(raw)) + for _, item := range raw { + number, err := parseIntValue(item) + if err != nil { + return nil, err + } + values = append(values, number) + } + return values, nil +} + +func parseIntValue(raw interface{}) (int, error) { + switch value := raw.(type) { + case float64: + return int(value), nil + case int: + return value, nil + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 0, fmt.Errorf("invalid numeric value: %v", raw) + } + return parsed, nil + default: + return 0, fmt.Errorf("invalid numeric value: %v", raw) + } +} + +func parseSliderSteps(steps []int) (int, []int, int, error) { + if len(steps) < 3 { + return 0, nil, 0, fmt.Errorf("slider steps payload too short") + } + + size := steps[0] + if size <= 0 { + return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size) + } + + remaining := append([]int(nil), steps[1:]...) + attempts := defaultSliderAttempts + if len(remaining)%2 != 0 { + attempts = remaining[len(remaining)-1] + remaining = remaining[:len(remaining)-1] + } + if attempts <= 0 { + attempts = defaultSliderAttempts + } + if len(remaining) == 0 || len(remaining)%2 != 0 { + return 0, nil, 0, fmt.Errorf("invalid slider swap payload") + } + + return size, remaining, attempts, nil +} + +func decodeSliderImage(rawImage string) (image.Image, error) { + decoded, err := base64.StdEncoding.DecodeString(rawImage) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + + img, _, err := image.Decode(bytes.NewReader(decoded)) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + + return img, nil +} + +func encodeSliderAnswer(activeSteps []int) (string, error) { + payload := struct { + Value []int `json:"value"` + }{ + Value: activeSteps, + } + + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(data), nil +} + +func buildSliderActiveSteps(swaps []int, candidateIndex int) []int { + if candidateIndex <= 0 { + return []int{} + } + + end := candidateIndex * 2 + if end > len(swaps) { + end = len(swaps) + } + + return append([]int(nil), swaps[:end]...) +} + +func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) { + tileCount := gridSize * gridSize + if tileCount <= 0 { + return nil, fmt.Errorf("invalid slider tile count: %d", tileCount) + } + if len(activeSteps)%2 != 0 { + return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps)) + } + + mapping := make([]int, tileCount) + for i := range mapping { + mapping[i] = i + } + + for idx := 0; idx < len(activeSteps); idx += 2 { + left := activeSteps[idx] + right := activeSteps[idx+1] + if left < 0 || right < 0 || left >= tileCount || right >= tileCount { + return nil, fmt.Errorf("slider step out of range: %d,%d", left, right) + } + mapping[left], mapping[right] = mapping[right], mapping[left] + } + + return mapping, nil +} + +func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) { + candidateCount := len(swaps) / 2 + if candidateCount == 0 { + return nil, fmt.Errorf("slider has no candidates") + } + + candidates := make([]sliderCandidate, 0, candidateCount) + for idx := 1; idx <= candidateCount; idx++ { + activeSteps := buildSliderActiveSteps(swaps, idx) + mapping, err := buildSliderTileMapping(gridSize, activeSteps) + if err != nil { + return nil, err + } + + score, err := scoreSliderCandidate(img, gridSize, mapping) + if err != nil { + return nil, err + } + + candidates = append(candidates, sliderCandidate{ + Index: idx, + ActiveSteps: activeSteps, + Score: score, + }) + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Score == candidates[j].Score { + return candidates[i].Index < candidates[j].Index + } + return candidates[i].Score < candidates[j].Score + }) + + return candidates, nil +} + +func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) { + rendered, err := renderSliderCandidate(img, gridSize, mapping) + if err != nil { + return 0, err + } + + return scoreRenderedSliderImage(rendered, gridSize), nil +} + +func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) { + if gridSize <= 0 { + return nil, fmt.Errorf("invalid grid size: %d", gridSize) + } + + tileCount := gridSize * gridSize + if len(mapping) != tileCount { + return nil, fmt.Errorf("unexpected tile mapping length: %d", len(mapping)) + } + + bounds := img.Bounds() + rendered := image.NewRGBA(bounds) + for dstIndex, srcIndex := range mapping { + srcRect := sliderTileRect(bounds, gridSize, srcIndex) + dstRect := sliderTileRect(bounds, gridSize, dstIndex) + copyScaledTile(rendered, dstRect, img, srcRect) + } + + return rendered, nil +} + +func scoreRenderedSliderImage(img image.Image, gridSize int) int64 { + bounds := img.Bounds() + var score int64 + + for row := 0; row < gridSize; row++ { + for col := 0; col < gridSize-1; col++ { + leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col) + rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1) + height := minInt(leftRect.Dy(), rightRect.Dy()) + for offset := 0; offset < height; offset++ { + score += pixelDiff( + img.At(leftRect.Max.X-1, leftRect.Min.Y+offset), + img.At(rightRect.Min.X, rightRect.Min.Y+offset), + ) + } + } + } + + for row := 0; row < gridSize-1; row++ { + for col := 0; col < gridSize; col++ { + topRect := sliderTileRect(bounds, gridSize, row*gridSize+col) + bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col) + width := minInt(topRect.Dx(), bottomRect.Dx()) + for offset := 0; offset < width; offset++ { + score += pixelDiff( + img.At(topRect.Min.X+offset, topRect.Max.Y-1), + img.At(bottomRect.Min.X+offset, bottomRect.Min.Y), + ) + } + } + } + + return score +} + +func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { + row := index / gridSize + col := index % gridSize + + x0 := bounds.Min.X + col*bounds.Dx()/gridSize + x1 := bounds.Min.X + (col+1)*bounds.Dx()/gridSize + y0 := bounds.Min.Y + row*bounds.Dy()/gridSize + y1 := bounds.Min.Y + (row+1)*bounds.Dy()/gridSize + + return image.Rect(x0, y0, x1, y1) +} + +func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) { + if dstRect.Empty() || srcRect.Empty() { + return + } + + dstWidth := dstRect.Dx() + dstHeight := dstRect.Dy() + srcWidth := srcRect.Dx() + srcHeight := srcRect.Dy() + + for y := 0; y < dstHeight; y++ { + sy := srcRect.Min.Y + y*srcHeight/dstHeight + for x := 0; x < dstWidth; x++ { + sx := srcRect.Min.X + x*srcWidth/dstWidth + dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, src.At(sx, sy)) + } + } +} + +func pixelDiff(left color.Color, right color.Color) int64 { + lr, lg, lb, _ := left.RGBA() + rr, rg, rb, _ := right.RGBA() + + return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb) +} + +func absDiff(left uint32, right uint32) int64 { + if left > right { + return int64(left - right) + } + return int64(right - left) +} + +func generateSliderCursor(candidateIndex int, candidateCount int) string { + return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli()) +} + +func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string { + if candidateCount <= 0 { + return "[]" + } + + type cursorPoint struct { + X int `json:"x"` + Y int `json:"y"` + T int64 `json:"t"` + } + + startX := 140 + endX := startX + 620*candidateIndex/candidateCount + startY := 430 + + points := make([]cursorPoint, 0, 12) + for step := 0; step < 12; step++ { + x := startX + (endX-startX)*step/11 + y := startY + ((step % 3) - 1) + points = append(points, cursorPoint{ + X: x, + Y: y, + T: startTime + int64(step*18), + }) + } + + data, err := json.Marshal(points) + if err != nil { + return "[]" + } + return string(data) +} + +func trySliderCaptchaCandidates( + candidates []sliderCandidate, + maxAttempts int, + check func(candidate sliderCandidate) (*captchaCheckResult, error), +) (string, error) { + if len(candidates) == 0 { + return "", fmt.Errorf("slider has no ranked candidates") + } + + limit := minInt(maxAttempts, len(candidates)) + if limit <= 0 { + return "", fmt.Errorf("slider has no attempts available") + } + + for idx := 0; idx < limit; idx++ { + result, err := check(candidates[idx]) + if err != nil { + return "", err + } + + switch result.Status { + case "OK": + if result.SuccessToken == "" { + return "", fmt.Errorf("success_token not found") + } + return result.SuccessToken, nil + case "ERROR_LIMIT": + return "", fmt.Errorf("slider check status: %s", result.Status) + default: + continue + } + } + + return "", fmt.Errorf("slider guesses exhausted") +} + +func minInt(left int, right int) int { + if left < right { + return left + } + return right +} + +func describeCaptchaTypes(settingsByType map[string]string) string { + if len(settingsByType) == 0 { + return "none" + } + + types := make([]string, 0, len(settingsByType)) + for captchaType := range settingsByType { + types = append(types, captchaType) + } + sort.Strings(types) + return strings.Join(types, ",") +} diff --git a/client/slider_captcha_test.go b/client/slider_captcha_test.go new file mode 100644 index 0000000..3db561a --- /dev/null +++ b/client/slider_captcha_test.go @@ -0,0 +1,278 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/jpeg" + "reflect" + "testing" +) + +func TestParseSliderSteps(t *testing.T) { + t.Parallel() + + size, swaps, attempts, err := parseSliderSteps([]int{5, 0, 1, 2, 3, 7}) + if err != nil { + t.Fatalf("parseSliderSteps returned error: %v", err) + } + + if size != 5 { + t.Fatalf("expected size 5, got %d", size) + } + if attempts != 7 { + t.Fatalf("expected attempts 7, got %d", attempts) + } + expected := []int{0, 1, 2, 3} + if !reflect.DeepEqual(swaps, expected) { + t.Fatalf("expected swaps %v, got %v", expected, swaps) + } +} + +func TestParseCaptchaSettingsResponseSupportsJSONStringMap(t *testing.T) { + t.Parallel() + + resp := map[string]interface{}{ + "response": map[string]interface{}{ + "show_captcha_type": "checkbox", + "captcha_settings": `{"slider":"slider-token","sound":"sound-token"}`, + }, + } + + settings, err := parseCaptchaSettingsResponse(resp) + if err != nil { + t.Fatalf("parseCaptchaSettingsResponse returned error: %v", err) + } + + if settings.ShowCaptchaType != "checkbox" { + t.Fatalf("expected show_captcha_type checkbox, got %q", settings.ShowCaptchaType) + } + if settings.SettingsByType["slider"] != "slider-token" { + t.Fatalf("expected slider settings token, got %q", settings.SettingsByType["slider"]) + } + if settings.SettingsByType["sound"] != "sound-token" { + t.Fatalf("expected sound settings token, got %q", settings.SettingsByType["sound"]) + } +} + +func TestParseCaptchaBootstrapHTML(t *testing.T) { + t.Parallel() + + html := ` + +` + + bootstrap, err := parseCaptchaBootstrapHTML(html) + if err != nil { + t.Fatalf("parseCaptchaBootstrapHTML returned error: %v", err) + } + + if bootstrap.PowInput != "abc123" { + t.Fatalf("expected pow input abc123, got %q", bootstrap.PowInput) + } + if bootstrap.Difficulty != 3 { + t.Fatalf("expected difficulty 3, got %d", bootstrap.Difficulty) + } + if bootstrap.Settings == nil { + t.Fatal("expected bootstrap settings") + } + if bootstrap.Settings.ShowCaptchaType != "checkbox" { + t.Fatalf("expected show_captcha_type checkbox, got %q", bootstrap.Settings.ShowCaptchaType) + } + if bootstrap.Settings.SettingsByType["slider"] != "slider-token" { + t.Fatalf("expected slider token, got %q", bootstrap.Settings.SettingsByType["slider"]) + } +} + +func TestRenderSliderCandidateMatchesSwapLayout(t *testing.T) { + t.Parallel() + + src := image.NewRGBA(image.Rect(0, 0, 20, 20)) + fillRect(src, image.Rect(0, 0, 10, 10), color.RGBA{255, 0, 0, 255}) + fillRect(src, image.Rect(10, 0, 20, 10), color.RGBA{0, 255, 0, 255}) + fillRect(src, image.Rect(0, 10, 10, 20), color.RGBA{0, 0, 255, 255}) + fillRect(src, image.Rect(10, 10, 20, 20), color.RGBA{255, 255, 0, 255}) + + mapping, err := buildSliderTileMapping(2, []int{0, 1}) + if err != nil { + t.Fatalf("buildSliderTileMapping returned error: %v", err) + } + + rendered, err := renderSliderCandidate(src, 2, mapping) + if err != nil { + t.Fatalf("renderSliderCandidate returned error: %v", err) + } + + assertPixelEquals(t, rendered.At(2, 2), color.RGBA{0, 255, 0, 255}) + assertPixelEquals(t, rendered.At(12, 2), color.RGBA{255, 0, 0, 255}) + assertPixelEquals(t, rendered.At(2, 12), color.RGBA{0, 0, 255, 255}) + assertPixelEquals(t, rendered.At(12, 12), color.RGBA{255, 255, 0, 255}) +} + +func TestRankSliderCandidatesPrefersMostCoherentImage(t *testing.T) { + t.Parallel() + + src := image.NewRGBA(image.Rect(0, 0, 30, 30)) + for y := 0; y < 30; y++ { + for x := 0; x < 30; x++ { + src.Set(x, y, color.RGBA{ + R: uint8(x * 5), + G: uint8(y * 5), + B: uint8((x + y) * 3), + A: 255, + }) + } + } + + candidates, err := rankSliderCandidates(src, 3, []int{0, 1, 0, 1}) + if err != nil { + t.Fatalf("rankSliderCandidates returned error: %v", err) + } + + if len(candidates) != 2 { + t.Fatalf("expected 2 candidates, got %d", len(candidates)) + } + if candidates[0].Index != 2 { + t.Fatalf("expected solved candidate to rank first, got candidate %d", candidates[0].Index) + } +} + +func TestEncodeSliderAnswer(t *testing.T) { + t.Parallel() + + encoded, err := encodeSliderAnswer([]int{9, 10, 2}) + if err != nil { + t.Fatalf("encodeSliderAnswer returned error: %v", err) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("failed to decode answer: %v", err) + } + + var payload struct { + Value []int `json:"value"` + } + if err := json.Unmarshal(decoded, &payload); err != nil { + t.Fatalf("failed to unmarshal answer payload: %v", err) + } + + expected := []int{9, 10, 2} + if !reflect.DeepEqual(payload.Value, expected) { + t.Fatalf("expected payload %v, got %v", expected, payload.Value) + } +} + +func TestTrySliderCaptchaCandidates(t *testing.T) { + t.Parallel() + + candidates := []sliderCandidate{ + {Index: 1, ActiveSteps: []int{0, 1}, Score: 10}, + {Index: 2, ActiveSteps: []int{0, 1, 0, 1}, Score: 20}, + } + + t.Run("success on first candidate", func(t *testing.T) { + token, err := trySliderCaptchaCandidates(candidates, 2, func(candidate sliderCandidate) (*captchaCheckResult, error) { + if candidate.Index != 1 { + t.Fatalf("unexpected candidate index %d", candidate.Index) + } + return &captchaCheckResult{Status: "OK", SuccessToken: "token-1"}, nil + }) + if err != nil { + t.Fatalf("trySliderCaptchaCandidates returned error: %v", err) + } + if token != "token-1" { + t.Fatalf("expected token-1, got %s", token) + } + }) + + t.Run("success on later candidate", func(t *testing.T) { + calls := 0 + token, err := trySliderCaptchaCandidates(candidates, 2, func(candidate sliderCandidate) (*captchaCheckResult, error) { + calls++ + if candidate.Index == 1 { + return &captchaCheckResult{Status: "BOT"}, nil + } + return &captchaCheckResult{Status: "OK", SuccessToken: "token-2"}, nil + }) + if err != nil { + t.Fatalf("trySliderCaptchaCandidates returned error: %v", err) + } + if calls != 2 { + t.Fatalf("expected 2 calls, got %d", calls) + } + if token != "token-2" { + t.Fatalf("expected token-2, got %s", token) + } + }) + + t.Run("exhausted candidates", func(t *testing.T) { + _, err := trySliderCaptchaCandidates(candidates, 1, func(candidate sliderCandidate) (*captchaCheckResult, error) { + return &captchaCheckResult{Status: "BOT"}, nil + }) + if err == nil { + t.Fatal("expected error after exhausting ranked candidates") + } + }) +} + +func TestParseSliderCaptchaContentResponse(t *testing.T) { + t.Parallel() + + src := image.NewRGBA(image.Rect(0, 0, 20, 20)) + fillRect(src, src.Bounds(), color.RGBA{12, 34, 56, 255}) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, src, nil); err != nil { + t.Fatalf("failed to encode jpeg fixture: %v", err) + } + + resp := map[string]interface{}{ + "response": map[string]interface{}{ + "status": "OK", + "extension": "jpeg", + "image": base64.StdEncoding.EncodeToString(buf.Bytes()), + "steps": []interface{}{float64(5), float64(0), float64(1), float64(2), float64(3), float64(6)}, + }, + } + + content, err := parseSliderCaptchaContentResponse(resp) + if err != nil { + t.Fatalf("parseSliderCaptchaContentResponse returned error: %v", err) + } + + if content.Size != 5 { + t.Fatalf("expected size 5, got %d", content.Size) + } + if content.Attempts != 6 { + t.Fatalf("expected attempts 6, got %d", content.Attempts) + } + if len(content.Steps) != 4 { + t.Fatalf("expected 4 swap entries, got %d", len(content.Steps)) + } +} + +func fillRect(img *image.RGBA, rect image.Rectangle, c color.Color) { + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + img.Set(x, y, c) + } + } +} + +func assertPixelEquals(t *testing.T, actual color.Color, expected color.RGBA) { + t.Helper() + + ar, ag, ab, aa := actual.RGBA() + if ar != uint32(expected.R)*0x101 || ag != uint32(expected.G)*0x101 || ab != uint32(expected.B)*0x101 || aa != uint32(expected.A)*0x101 { + t.Fatalf("expected pixel %+v, got rgba(%d,%d,%d,%d)", expected, ar, ag, ab, aa) + } +}