refactor: auth and wayfern

This commit is contained in:
zhom
2026-04-25 16:30:59 +04:00
parent 658d428a62
commit a322c97d64
13 changed files with 231 additions and 441 deletions
+69 -32
View File
@@ -7,6 +7,7 @@ use chrono::Utc;
use lazy_static::lazy_static;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
@@ -54,12 +55,15 @@ pub struct CloudAuthState {
}
#[derive(Debug, Deserialize)]
struct OtpRequestResponse {
message: String,
struct DeviceCodeChallengeResponse {
#[serde(rename = "challengeId")]
challenge_id: String,
prefix: String,
difficulty: u32,
}
#[derive(Debug, Deserialize)]
struct OtpVerifyResponse {
struct DeviceCodeExchangeResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "refreshToken")]
@@ -362,47 +366,49 @@ impl CloudAuthManager {
// --- API methods ---
pub async fn request_otp(&self, email: &str, captcha_token: &str) -> Result<String, String> {
let url = format!("{CLOUD_API_URL}/api/auth/otp/request");
let response = self
pub async fn exchange_device_code(&self, code: &str) -> Result<CloudAuthState, String> {
let challenge_url = format!("{CLOUD_API_URL}/api/auth/device-code/challenge");
let challenge_response = self
.client
.post(&url)
.json(&serde_json::json!({ "email": email, "captchaToken": captcha_token }))
.post(&challenge_url)
.send()
.await
.map_err(|e| format!("Failed to request OTP: {e}"))?;
.map_err(|e| format!("Failed to fetch challenge: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("OTP request failed ({status}): {body}"));
if !challenge_response.status().is_success() {
let status = challenge_response.status();
let body = challenge_response.text().await.unwrap_or_default();
return Err(format!("Challenge request failed ({status}): {body}"));
}
let result: OtpRequestResponse = response
let challenge: DeviceCodeChallengeResponse = challenge_response
.json()
.await
.map_err(|e| format!("Failed to parse response: {e}"))?;
.map_err(|e| format!("Failed to parse challenge: {e}"))?;
Ok(result.message)
}
let nonce = solve_pow(&challenge.prefix, challenge.difficulty)
.ok_or_else(|| "Failed to solve proof-of-work".to_string())?;
pub async fn verify_otp(&self, email: &str, code: &str) -> Result<CloudAuthState, String> {
let url = format!("{CLOUD_API_URL}/api/auth/otp/verify");
let exchange_url = format!("{CLOUD_API_URL}/api/auth/device-code/exchange");
let response = self
.client
.post(&url)
.json(&serde_json::json!({ "email": email, "code": code }))
.post(&exchange_url)
.json(&serde_json::json!({
"code": code,
"challengeId": challenge.challenge_id,
"nonce": nonce,
}))
.send()
.await
.map_err(|e| format!("Failed to verify OTP: {e}"))?;
.map_err(|e| format!("Failed to verify code: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("OTP verification failed ({status}): {body}"));
return Err(format!("Login failed ({status}): {body}"));
}
let result: OtpVerifyResponse = response
let result: DeviceCodeExchangeResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {e}"))?;
@@ -1097,20 +1103,51 @@ impl CloudAuthManager {
}
}
fn solve_pow(prefix: &str, difficulty: u32) -> Option<String> {
if difficulty == 0 || difficulty > 32 {
return None;
}
let prefix_bytes = prefix.as_bytes();
let mut buf = Vec::with_capacity(prefix_bytes.len() + 24);
for nonce in 0u64..u64::MAX {
buf.clear();
buf.extend_from_slice(prefix_bytes);
let nonce_str = nonce.to_string();
buf.extend_from_slice(nonce_str.as_bytes());
let digest = Sha256::digest(&buf);
if has_leading_zero_bits(&digest, difficulty) {
return Some(nonce_str);
}
}
None
}
fn has_leading_zero_bits(digest: &[u8], bits: u32) -> bool {
let full_bytes = (bits / 8) as usize;
if digest.len() < full_bytes + 1 {
return false;
}
for &b in &digest[..full_bytes] {
if b != 0 {
return false;
}
}
let remainder = bits % 8;
if remainder == 0 {
return true;
}
let mask = 0xffu8 << (8 - remainder);
(digest[full_bytes] & mask) == 0
}
// --- Tauri commands ---
#[tauri::command]
pub async fn cloud_request_otp(email: String, captcha_token: String) -> Result<String, String> {
CLOUD_AUTH.request_otp(&email, &captcha_token).await
}
#[tauri::command]
pub async fn cloud_verify_otp(
pub async fn cloud_exchange_device_code(
app_handle: tauri::AppHandle,
email: String,
code: String,
) -> Result<CloudAuthState, String> {
let state = CLOUD_AUTH.verify_otp(&email, &code).await?;
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!(
+3 -3
View File
@@ -2087,9 +2087,9 @@ pub fn run() {
disconnect_vpn,
get_vpn_status,
list_active_vpn_connections,
handle_url_open,
// Cloud auth commands
cloud_auth::cloud_request_otp,
cloud_auth::cloud_verify_otp,
cloud_auth::cloud_exchange_device_code,
cloud_auth::cloud_get_user,
cloud_auth::cloud_refresh_profile,
cloud_auth::cloud_logout,
@@ -2278,7 +2278,7 @@ mod tests {
// Remove trailing comma and whitespace
let command = line.trim_end_matches(',').trim();
if !command.is_empty() {
// Strip module prefix (e.g., "cloud_auth::cloud_request_otp" -> "cloud_request_otp")
// Strip module prefix (e.g., "cloud_auth::cloud_get_user" -> "cloud_get_user")
let command = command.rsplit("::").next().unwrap_or(command);
commands.push(command.to_string());
}
+64 -96
View File
@@ -1,6 +1,5 @@
use crate::browser_runner::BrowserRunner;
use crate::profile::BrowserProfile;
use playwright::api::Playwright;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -60,8 +59,6 @@ struct WayfernInstance {
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
playwright_context: Option<playwright::api::BrowserContext>,
playwright_runtime: Option<Playwright>,
}
struct WayfernManagerInner {
@@ -94,16 +91,6 @@ impl WayfernManager {
}
}
async fn create_playwright(
&self,
) -> Result<Playwright, Box<dyn std::error::Error + Send + Sync>> {
Playwright::initialize()
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to initialize Playwright: {e}").into()
})
}
pub fn instance() -> &'static WayfernManager {
&WAYFERN_MANAGER
}
@@ -514,7 +501,7 @@ impl WayfernManager {
Some(p) => p,
None => Self::find_free_port().await?,
};
log::info!("Launching Wayfern on CDP port {port}");
log::info!("Launching Wayfern on CDP port {port} (detached)");
// Diagnostic: verify critical profile files and test cookie decryption
{
@@ -607,6 +594,7 @@ impl WayfernManager {
let mut args = vec![
format!("--remote-debugging-port={port}"),
"--remote-debugging-address=127.0.0.1".to_string(),
format!("--user-data-dir={profile_path}"),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
@@ -622,6 +610,10 @@ impl WayfernManager {
"--password-store=basic".to_string(),
];
if headless {
args.push("--headless=new".to_string());
}
#[cfg(target_os = "linux")]
{
args.push("--no-sandbox".to_string());
@@ -670,51 +662,28 @@ impl WayfernManager {
args.push("--dns-prefetch-disable".to_string());
}
let pw = self.create_playwright().await?;
let chromium = pw.chromium();
let profile_path_ref = std::path::Path::new(profile_path);
let mut launcher = chromium.persistent_context_launcher(profile_path_ref);
launcher = launcher.executable(executable_path.as_ref());
launcher = launcher.headless(headless);
launcher = launcher.chromium_sandbox(true);
launcher = launcher.args(&args);
launcher = launcher.timeout(0.0);
let mut command = TokioCommand::new(&executable_path);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let pw_context =
launcher
.launch()
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
let hint = if format!("{e}").contains("14001") {
". This usually means the Visual C++ Redistributable is not installed. \
let child = command
.spawn()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
let hint = if e.raw_os_error() == Some(14001) {
". This usually means the Visual C++ Redistributable is not installed. \
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
} else {
""
};
format!("Failed to launch Wayfern: {e}{hint}").into()
})?;
} else {
""
};
format!("Failed to spawn Wayfern: {e}{hint}").into()
})?;
let process_id = child.id();
drop(child);
let process_id = {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let mut found: Option<u32> = None;
for (pid, process) in system.processes() {
let cmd_str = process
.cmd()
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ");
if cmd_str.contains(&format!("--remote-debugging-port={port}")) {
found = Some(pid.as_u32());
break;
}
}
found
};
let pw_runtime = pw;
self.wait_for_cdp_ready(port).await?;
let targets = self.get_cdp_targets(port).await?;
log::info!("Found {} CDP targets", targets.len());
@@ -797,7 +766,6 @@ impl WayfernManager {
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
// Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped
match self
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
@@ -816,23 +784,20 @@ impl WayfernManager {
// Geolocation is handled internally by the browser binary.
// Navigate to URL via CDP - fingerprint will be applied at navigation commit time
if let Some(url) = url {
log::info!("Navigating to URL via CDP: {}", url);
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
match self
if let Err(e) = self
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": url }))
.await
{
Ok(_) => log::info!("Successfully navigated to URL: {}", url),
Err(e) => log::error!("Failed to navigate to URL: {e}"),
log::error!("Failed to navigate to URL: {e}");
}
}
}
}
// Clear Playwright's emulation overrides that cause tampering detection
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
let _ = self
@@ -862,8 +827,6 @@ impl WayfernManager {
profile_path: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
playwright_context: Some(pw_context),
playwright_runtime: Some(pw_runtime),
};
let mut inner = self.inner.lock().await;
@@ -886,8 +849,6 @@ impl WayfernManager {
if let Some(instance) = inner.instances.remove(id) {
log::info!("Cleaning up Wayfern instance {}", instance.id);
drop(instance.playwright_context);
drop(instance.playwright_runtime);
if let Some(pid) = instance.process_id {
#[cfg(unix)]
{
@@ -911,40 +872,49 @@ impl WayfernManager {
Ok(())
}
/// Opens a URL in a new tab for an existing Wayfern instance using CDP.
/// Returns Ok(()) if successful, or an error if the instance is not found or CDP fails.
/// Opens a URL in a new tab for an existing Wayfern instance.
pub async fn open_url_in_tab(
&self,
profile_path: &str,
url: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let instance = self
.find_wayfern_by_profile(profile_path)
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
let port = inner
.instances
.values()
.find(|i| {
i.profile_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(p).to_path_buf())
== target_path
})
.unwrap_or(false)
})
.and_then(|i| i.cdp_port)
.ok_or("Wayfern instance (with CDP port) not found for profile")?;
drop(inner);
// Open the URL in a new tab via the CDP HTTP convenience endpoint.
let new_tab_url = format!(
"http://127.0.0.1:{port}/json/new?{}",
urlencoding::encode(url)
);
let resp = self
.http_client
.put(&new_tab_url)
.send()
.await
.ok_or("Wayfern instance not found for profile")?;
let cdp_port = instance
.cdp_port
.ok_or("No CDP port available for Wayfern instance")?;
// Get the browser target to create a new tab
let targets = self.get_cdp_targets(cdp_port).await?;
// Find a page target to get the WebSocket URL (we need any target to send commands)
let page_target = targets
.iter()
.find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some())
.ok_or("No page target found for CDP")?;
let ws_url = page_target
.websocket_debugger_url
.as_ref()
.ok_or("No WebSocket URL available")?;
// Use Target.createTarget to open a new tab with the URL
self
.send_cdp_command(ws_url, "Target.createTarget", json!({ "url": url }))
.await?;
.map_err(|e| format!("Failed to open new tab: {e}"))?;
if !resp.status().is_success() {
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
}
log::info!("Opened URL in new tab via CDP: {}", url);
Ok(())
@@ -1042,8 +1012,6 @@ impl WayfernManager {
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
playwright_context: None,
playwright_runtime: None,
},
);