Browse Source
Three intertwined fixes that together make the client usable again on Android and against the new VK captcha flow that started 2026-06-25. == Fix #182 — captcha parser for new VK error format == VK started returning a captcha-required error (error_code 14) at the calls.getAnonymousToken step in a NEW shape that only carries: error_code, error_msg, is_enabled_captcha, redirect_uri and notably NO captcha_sid and NO captcha_img. The previous parser rejected this payload with "missing captcha_sid in captcha error data" and returned nil, so the auth loop never reached the captcha solver and just fell through to the next client_id — exactly the regression reported in issue #182. ParseVkCaptchaError now treats redirect_uri, captcha_sid, captcha_img and error_msg as optional and only requires error_code plus at least one solvable path (redirect_uri+session_token OR captcha_img/captcha_sid). The new flow's request body no longer sends an empty captcha_sid when only success_token is available. Tests in captcha_parse_test.go cover: - new format without captcha_sid (issue #182 repro) - legacy format with captcha_sid + captcha_img - numeric captcha_sid - unsolvable payload returns nil - missing error_code returns nil == Android TLS — embedded Mozilla CA bundle == VK switched *.vk.com to a certificate issued by HARICA TLS RSA Root CA 2021 (a Greek CA added to the Mozilla trust store only in 2022). The bogdanfinn/tls-client library leaves RootCAs as nil by default, which makes Go fall back to x509.SystemCertPool() — and that pool is EMPTY on Android apps (no /etc/ssl/certs/), so every HTTPS request failed with: tls: failed to verify certificate: x509: certificate signed by unknown authority A new client/ca_bundle.go helper loads a CA pool with the following priority: 1. SSL_CERT_FILE env var (if set) 2. x509.SystemCertPool() (desktops/servers) 3. Embedded Mozilla CA bundle (client/certs/cacert.pem, 189 KB, contains HARICA TLS RSA/ECC Root CA 2021) The embedded bundle is the same one curl ships, downloaded from https://curl.se/ca/cacert.pem. A VKTURN_INSECURE_TLS env var is honored for emergency debugging (not exposed as a CLI flag — production should always verify). == Auto-captcha improvements == After reverse-engineering not_robot_captcha.js (878 KB), I found three bugs in the existing inline captchaNotRobot.check flow that each alone caused VK to return status:BOT: 1. PoW hash was sent as bare hex. VK's JS produces: window.captchaPowResult = "v2." + btoa(JSON.stringify({hash, nonce})) Now solvePoW returns (hash, nonce) and formatPoWResult wraps them. 2. debug_info was a random per-request MD5. VK's JS sends either window.vk.brlefapmjnpg or the hardcoded fallback "4045edac1cb2c18eff209dc09cd5e2e56475a13ed4615413a87618b3c6813f9a". Now uses the hardcoded fallback. 3. captchaNotRobot.initSession was never called. VK's JS always calls it before settings; without it the server-side session state is not fully initialized. Now called as Step 0/4 in both callCaptchaNotRobot and callCaptchaNotRobotWithSliderPOC. Also fixed: - connectionRtt was a constant [50,50,50,...] — now generated with realistic jitter (45-84ms baseline, occasional spikes). - connectionDownlink was a constant [9.5,9.5,...] — now generated with mild per-sample jitter. - check response is now logged (status, show_captcha_type, redirect) so future debugging is easier. NOTE: even with all three fixes, VK still returns status:BOT when the client runs from a datacenter IP or without a real browser. The reason is browser_fp = FingerprintJS visitor_id (computed via canvas/webgl/ font fingerprinting in the browser) — this cannot be faked from Go code. The next commit adds a Playwright-based solver that runs a real headless Chromium to produce a valid browser_fp.pull/183/head
6 changed files with 3536 additions and 47 deletions
@ -0,0 +1,82 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"crypto/x509" |
|||
_ "embed" |
|||
"log" |
|||
"os" |
|||
"strings" |
|||
) |
|||
|
|||
//go:embed certs/cacert.pem
|
|||
var mozillaCABundlePEM []byte |
|||
|
|||
// loadCABundle returns a *x509.CertPool that can verify VK (and any other
|
|||
// TLS site) even on platforms where the system certificate store is empty
|
|||
// or unavailable — most notably Android apps that ship without /etc/ssl/certs
|
|||
// and have no access to the OS cert store.
|
|||
//
|
|||
// Strategy:
|
|||
// 1. Try x509.SystemCertPool(). If it returns a non-empty pool, use it
|
|||
// (this is the normal case on Linux/macOS/Windows desktops and servers).
|
|||
// 2. If SystemCertPool is unavailable or empty, fall back to the embedded
|
|||
// Mozilla CA bundle (cacert.pem, the same one curl ships). This contains
|
|||
// every CA in the Mozilla trust program, including HARICA TLS RSA/ECC
|
|||
// Root CA 2021 which VK uses for *.vk.com since mid-2026.
|
|||
// 3. Also honor the SSL_CERT_FILE / SSL_CERT_DIR env vars if set, so users
|
|||
// can override on unusual systems.
|
|||
//
|
|||
// Without this, on Android the tls-client (bogdanfinn) leaves RootCAs == nil,
|
|||
// which makes Go fall back to SystemCertPool — and on Android that pool is
|
|||
// empty, so every HTTPS request fails with:
|
|||
//
|
|||
// tls: failed to verify certificate: x509: certificate signed by unknown authority
|
|||
//
|
|||
// See: https://github.com/cacggghp/vk-turn-proxy/issues/182 (and follow-ups).
|
|||
func loadCABundle() *x509.CertPool { |
|||
// 1. Honor explicit env overrides first (mirrors crypto/x509 behavior).
|
|||
if file := os.Getenv("SSL_CERT_FILE"); file != "" { |
|||
if p, err := loadPoolFromFile(file); err == nil && p != nil { |
|||
log.Printf("[CABundle] using SSL_CERT_FILE=%s", file) |
|||
return p |
|||
} |
|||
} |
|||
|
|||
// 2. Try the system pool.
|
|||
if sys, err := x509.SystemCertPool(); err == nil && sys != nil && len(sys.Subjects()) > 0 { |
|||
log.Printf("[CABundle] using system cert pool (%d roots)", len(sys.Subjects())) |
|||
return sys |
|||
} |
|||
|
|||
// 3. Fall back to the embedded Mozilla bundle.
|
|||
pool := x509.NewCertPool() |
|||
if !pool.AppendCertsFromPEM(mozillaCABundlePEM) { |
|||
log.Printf("[CABundle] WARNING: embedded Mozilla bundle did not parse; HTTPS will likely fail") |
|||
} else { |
|||
log.Printf("[CABundle] using embedded Mozilla CA bundle (%d bytes)", len(mozillaCABundlePEM)) |
|||
} |
|||
return pool |
|||
} |
|||
|
|||
// loadPoolFromFile loads a PEM file with one or more certificates into a fresh
|
|||
// CertPool. Returns nil if the file contained no usable certificates.
|
|||
func loadPoolFromFile(path string) (*x509.CertPool, error) { |
|||
data, err := os.ReadFile(path) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
pool := x509.NewCertPool() |
|||
if !pool.AppendCertsFromPEM(data) { |
|||
return nil, nil |
|||
} |
|||
return pool, nil |
|||
} |
|||
|
|||
// hasHTTPSInsecureEnv returns true if the user explicitly requested skipping
|
|||
// TLS verification (useful for debugging MITM proxies or stale CA bundles).
|
|||
// This is intentionally NOT exposed as a CLI flag — production should always
|
|||
// verify.
|
|||
func hasHTTPSInsecureEnv() bool { |
|||
v := strings.ToLower(os.Getenv("VKTURN_INSECURE_TLS")) |
|||
return v == "1" || v == "true" || v == "yes" |
|||
} |
|||
@ -0,0 +1,146 @@ |
|||
package main |
|||
|
|||
import "testing" |
|||
|
|||
// TestParseVkCaptchaError_NewFormatWithoutCaptchaSid reproduces issue #182.
|
|||
//
|
|||
// VK started returning a captcha-required error (error_code 14) at the
|
|||
// calls.getAnonymousToken step in a NEW shape that only carries:
|
|||
//
|
|||
// error_code, error_msg, is_enabled_captcha, redirect_uri
|
|||
//
|
|||
// and notably NO captcha_sid and NO captcha_img. The previous parser
|
|||
// rejected this payload with "missing captcha_sid in captcha error data"
|
|||
// and returned nil, so the auth loop never reached the captcha solver and
|
|||
// just fell through to the next client_id.
|
|||
//
|
|||
// After the fix, ParseVkCaptchaError must return a non-nil *VkCaptchaError
|
|||
// with RedirectURI + SessionToken populated so both the auto solver and the
|
|||
// manual (browser) solver can run.
|
|||
func TestParseVkCaptchaError_NewFormatWithoutCaptchaSid(t *testing.T) { |
|||
t.Parallel() |
|||
|
|||
// Shape taken verbatim from the issue #182 log (session_token truncated
|
|||
// for readability but structurally identical to the real payload).
|
|||
errData := map[string]interface{}{ |
|||
"error_code": float64(14), |
|||
"error_msg": "Captcha need", |
|||
"is_enabled_captcha": true, |
|||
"redirect_uri": "https://id.vk.ru/not_robot_captcha?domain=vk.com&session_token=eyJhbGciOiJBMjU2R0NNS1ci.payload.signature&variant=popup&blank=1", |
|||
"captcha_stid": "some-stid", // unrelated field, must be ignored
|
|||
} |
|||
|
|||
got := ParseVkCaptchaError(errData) |
|||
if got == nil { |
|||
t.Fatal("expected non-nil VkCaptchaError for new captcha format, got nil") |
|||
} |
|||
|
|||
if got.ErrorCode != 14 { |
|||
t.Fatalf("ErrorCode = %d, want 14", got.ErrorCode) |
|||
} |
|||
if got.ErrorMsg != "Captcha need" { |
|||
t.Fatalf("ErrorMsg = %q, want %q", got.ErrorMsg, "Captcha need") |
|||
} |
|||
if got.RedirectURI == "" { |
|||
t.Fatal("RedirectURI is empty, expected the not_robot_captcha URL") |
|||
} |
|||
if got.SessionToken == "" { |
|||
t.Fatal("SessionToken is empty, expected it to be parsed from redirect_uri query") |
|||
} |
|||
if got.SessionToken != "eyJhbGciOiJBMjU2R0NNS1ci.payload.signature" { |
|||
t.Fatalf("SessionToken = %q, want the session_token query value", got.SessionToken) |
|||
} |
|||
// captcha_sid / captcha_img are absent in the new format and must default
|
|||
// to empty strings (NOT cause the parser to bail out).
|
|||
if got.CaptchaSid != "" { |
|||
t.Fatalf("CaptchaSid = %q, want empty (not present in new format)", got.CaptchaSid) |
|||
} |
|||
if got.CaptchaImg != "" { |
|||
t.Fatalf("CaptchaImg = %q, want empty (not present in new format)", got.CaptchaImg) |
|||
} |
|||
|
|||
if !got.IsCaptchaError() { |
|||
t.Fatal("IsCaptchaError() = false, want true (new flow is solvable via redirect_uri + session_token)") |
|||
} |
|||
} |
|||
|
|||
// TestParseVkCaptchaError_LegacyFormatStillWorks ensures the legacy captcha
|
|||
// payload (captcha_sid + captcha_img, no redirect_uri) still parses so the
|
|||
// manual HTTP captcha path keeps working.
|
|||
func TestParseVkCaptchaError_LegacyFormatStillWorks(t *testing.T) { |
|||
t.Parallel() |
|||
|
|||
errData := map[string]interface{}{ |
|||
"error_code": float64(14), |
|||
"error_msg": "Captcha need", |
|||
"captcha_sid": "1234567890", |
|||
"captcha_img": "https://api.vk.com/captcha.php?sid=1234567890&s=1", |
|||
} |
|||
|
|||
got := ParseVkCaptchaError(errData) |
|||
if got == nil { |
|||
t.Fatal("expected non-nil VkCaptchaError for legacy captcha format, got nil") |
|||
} |
|||
if got.CaptchaSid != "1234567890" { |
|||
t.Fatalf("CaptchaSid = %q, want 1234567890", got.CaptchaSid) |
|||
} |
|||
if got.CaptchaImg == "" { |
|||
t.Fatal("CaptchaImg is empty, expected the captcha image URL") |
|||
} |
|||
if got.RedirectURI != "" { |
|||
t.Fatalf("RedirectURI = %q, want empty in legacy flow", got.RedirectURI) |
|||
} |
|||
} |
|||
|
|||
// TestParseVkCaptchaError_NumericCaptchaSid covers the case where VK returns
|
|||
// captcha_sid as a JSON number (unmarshalled into float64).
|
|||
func TestParseVkCaptchaError_NumericCaptchaSid(t *testing.T) { |
|||
t.Parallel() |
|||
|
|||
errData := map[string]interface{}{ |
|||
"error_code": float64(14), |
|||
"error_msg": "Captcha need", |
|||
"captcha_sid": float64(9876543210), |
|||
"captcha_img": "https://api.vk.com/captcha.php?sid=9876543210", |
|||
} |
|||
|
|||
got := ParseVkCaptchaError(errData) |
|||
if got == nil { |
|||
t.Fatal("expected non-nil VkCaptchaError, got nil") |
|||
} |
|||
if got.CaptchaSid != "9876543210" { |
|||
t.Fatalf("CaptchaSid = %q, want 9876543210", got.CaptchaSid) |
|||
} |
|||
} |
|||
|
|||
// TestParseVkCaptchaError_UnsolvableReturnsNil verifies that when none of the
|
|||
// solvable fields are present, the parser returns nil so the caller surfaces
|
|||
// the raw VK error instead of pretending it can solve a captcha it cannot.
|
|||
func TestParseVkCaptchaError_UnsolvableReturnsNil(t *testing.T) { |
|||
t.Parallel() |
|||
|
|||
errData := map[string]interface{}{ |
|||
"error_code": float64(14), |
|||
"error_msg": "Captcha need", |
|||
// no redirect_uri, no captcha_sid, no captcha_img
|
|||
} |
|||
|
|||
if got := ParseVkCaptchaError(errData); got != nil { |
|||
t.Fatalf("expected nil for unsolvable captcha payload, got %+v", got) |
|||
} |
|||
} |
|||
|
|||
// TestParseVkCaptchaError_MissingErrorCodeReturnsNil verifies the parser still
|
|||
// rejects payloads that don't even identify as a VK API error object.
|
|||
func TestParseVkCaptchaError_MissingErrorCodeReturnsNil(t *testing.T) { |
|||
t.Parallel() |
|||
|
|||
errData := map[string]interface{}{ |
|||
"error_msg": "Captcha need", |
|||
"redirect_uri": "https://id.vk.ru/not_robot_captcha?session_token=x", |
|||
} |
|||
|
|||
if got := ParseVkCaptchaError(errData); got != nil { |
|||
t.Fatalf("expected nil when error_code is missing, got %+v", got) |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue