mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
added webgtk recaptcha
This commit is contained in:
1 parent
ed129f7c71
commit
cb67d97b0f
10 files changed
+407
-35
No files matched your search
Generated
+94
-11
@@ -1567,7 +1567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2697,6 +2697,29 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "javascriptcore6"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03b28ed9c7c08f906b2a51bc2365eae2ba5e7db1249b89892f7ae4cbd602d1f4"
|
||||||
|
dependencies = [
|
||||||
|
"glib",
|
||||||
|
"javascriptcore6-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "javascriptcore6-sys"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4741e2a31c2145050dd4971f8dd51e92c840d5839a7124cc68a33c7325523a12"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -3179,7 +3202,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3558,6 +3581,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"webkit6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3805,9 +3829,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91976e4b6427b43ec8db405ee277d88d2c17d67d08bd5106a0e519a7d33dda79"
|
checksum = "9a76197ffe7c9588c0bc1fa38a51e8aa8642b10e81ec16272d4962bd60525920"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3832,9 +3856,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f75bc4244b97c64255268d2a22a0045d3dceb35f9fa99bb3854a7aced9e92c7"
|
checksum = "805df5ff2805e0e935ac7f5a237482231208a706d8c6ab3dd91157ae2e95c9f6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3951,7 +3975,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4366,7 +4390,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4423,7 +4447,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4778,6 +4802,32 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soup3"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b84ccd1f4aee0854a16b0b489ba843798e2eb4cdcddd4a61248f7db9ce8b6df1"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"soup3-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soup3-sys"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8869997193d52a61a1db48627bdaa57343f76e2c5132ee6d351245a6ab30631e"
|
||||||
|
dependencies = [
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -4890,7 +4940,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5477,6 +5527,39 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webkit6"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c359ef247305dcade3363c281c505b943e0e6162a42eac76ff76ed8e7cebfbd"
|
||||||
|
dependencies = [
|
||||||
|
"gdk4",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"gtk4",
|
||||||
|
"javascriptcore6",
|
||||||
|
"libc",
|
||||||
|
"soup3",
|
||||||
|
"webkit6-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webkit6-sys"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96284c5280af5984dbdae8dae3cfeea11b44b214f9bd42b35c0ca75903bccce2"
|
||||||
|
dependencies = [
|
||||||
|
"gdk4-sys",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk4-sys",
|
||||||
|
"javascriptcore6-sys",
|
||||||
|
"libc",
|
||||||
|
"soup3-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -5523,7 +5606,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.1.10" }
|
proton-sdk = { version = "0.1" }
|
||||||
proton-drive-rs = { version = "0.1.10" }
|
proton-drive-rs = { version = "0.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -817,8 +817,19 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
||||||
|
// carrying. Point at the app that does rather than failing with the raw API
|
||||||
|
// message, which reads as "your login is broken".
|
||||||
rt.block_on(auth::login(&username, &password, get_totp))
|
rt.block_on(auth::login(&username, &password, get_totp))
|
||||||
.context("login failed")?;
|
.map_err(|e| match e {
|
||||||
|
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
||||||
|
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||||
|
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
||||||
|
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
||||||
|
your usual network often avoids it.)"
|
||||||
|
),
|
||||||
|
other => anyhow::Error::new(other).context("login failed"),
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -84,14 +85,68 @@ fn keyring_entry() -> Result<Entry> {
|
|||||||
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
||||||
///
|
///
|
||||||
/// `get_totp` is only invoked when the account requires a second factor, so
|
/// `get_totp` is only invoked when the account requires a second factor, so
|
||||||
/// callers can defer prompting until it is actually needed.
|
/// callers can defer prompting until it is actually needed. It may be called
|
||||||
|
/// more than once — [`login_interactive`] re-runs the whole login after human
|
||||||
|
/// verification, and a 2FA account needs a fresh code on that second attempt.
|
||||||
|
///
|
||||||
|
/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end
|
||||||
|
/// that can present the challenge should use [`login_interactive`] instead.
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
get_totp: impl FnOnce() -> Result<String>,
|
get_totp: impl Fn() -> Result<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut session =
|
login_verified(username, password, None, get_totp).await
|
||||||
ProtonApiSession::begin(client_config(), username, password.as_bytes()).await?;
|
}
|
||||||
|
|
||||||
|
/// [`login`], able to answer a human-verification gate rather than fail on it.
|
||||||
|
///
|
||||||
|
/// `get_hv` is invoked only when the API actually gates the login, mirroring how
|
||||||
|
/// `get_totp` is only invoked for accounts with a second factor: neither costs a
|
||||||
|
/// prompt the user did not need. It receives the challenge and returns the token
|
||||||
|
/// the user earned by solving it.
|
||||||
|
///
|
||||||
|
/// The retry lives here rather than in the front-ends because the recovery is
|
||||||
|
/// not a UI concern: the gated attempt burned its SRP handshake, so the login
|
||||||
|
/// has to start over with the credential attached, and every front-end would
|
||||||
|
/// otherwise have to know that. Gating the *retry* is not handled — a second
|
||||||
|
/// challenge in a row means the token was rejected, and looping on it would trap
|
||||||
|
/// the user in a CAPTCHA that never clears.
|
||||||
|
pub async fn login_interactive(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
||||||
|
) -> Result<()> {
|
||||||
|
match login_verified(username, password, None, &get_totp).await {
|
||||||
|
Err(Error::HumanVerificationRequired(hv)) => {
|
||||||
|
let credential = get_hv(*hv)?;
|
||||||
|
login_verified(username, password, Some(&credential), &get_totp).await
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`login`], replaying a human-verification token the user has already earned.
|
||||||
|
///
|
||||||
|
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
||||||
|
/// challenge. The front-end presents it, collects the solved token, and calls
|
||||||
|
/// this. The login starts over from the SRP handshake rather than resuming: the
|
||||||
|
/// gated attempt never got a session, and its `srp_session` is spent.
|
||||||
|
pub async fn login_verified(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
verification: Option<&HumanVerificationCredential>,
|
||||||
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut session = ProtonApiSession::begin_verified(
|
||||||
|
client_config(),
|
||||||
|
username,
|
||||||
|
password.as_bytes(),
|
||||||
|
verification,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(classify_verification_gate)?;
|
||||||
|
|
||||||
if session.is_waiting_for_second_factor() {
|
if session.is_waiting_for_second_factor() {
|
||||||
let code = get_totp()?;
|
let code = get_totp()?;
|
||||||
@@ -110,6 +165,23 @@ pub async fn login(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lift a human-verification gate out of the generic API error into the typed
|
||||||
|
/// variant a front-end can act on.
|
||||||
|
///
|
||||||
|
/// Only a gate that names a solvable method is converted. A `9001` with no
|
||||||
|
/// `Details`, or one offering only `email`/`sms`, stays a plain API error:
|
||||||
|
/// promoting it would send the UI to open a verification page it cannot
|
||||||
|
/// complete, which reads to the user as a hang rather than a refusal.
|
||||||
|
fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error {
|
||||||
|
let proton_sdk::error::ProtonError::Api(api) = &e else {
|
||||||
|
return e.into();
|
||||||
|
};
|
||||||
|
match api.human_verification() {
|
||||||
|
Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)),
|
||||||
|
_ => e.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist the session's current tokens, mailbox password and key salts.
|
/// Persist the session's current tokens, mailbox password and key salts.
|
||||||
pub async fn save(
|
pub async fn save(
|
||||||
session: &ProtonApiSession,
|
session: &ProtonApiSession,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Crate-wide error type.
|
//! Crate-wide error type.
|
||||||
|
|
||||||
|
use proton_sdk::api::HumanVerification;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::control::ErrorKind;
|
use crate::control::ErrorKind;
|
||||||
@@ -31,6 +32,15 @@ pub enum Error {
|
|||||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||||
ReloginRequired,
|
ReloginRequired,
|
||||||
|
|
||||||
|
/// The login was gated behind human verification. Not a failure the user can
|
||||||
|
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||||
|
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||||
|
///
|
||||||
|
/// Carries the challenge rather than just a message so a front-end can
|
||||||
|
/// present it; a front-end that cannot (no webview) still has prose to show.
|
||||||
|
#[error("sign-in needs human verification — complete the CAPTCHA to continue")]
|
||||||
|
HumanVerificationRequired(Box<HumanVerification>),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
@@ -154,7 +164,9 @@ impl From<serde_json::Error> for CoreError {
|
|||||||
impl From<Error> for CoreError {
|
impl From<Error> for CoreError {
|
||||||
fn from(e: Error) -> Self {
|
fn from(e: Error) -> Self {
|
||||||
let kind = match &e {
|
let kind = match &e {
|
||||||
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
|
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
||||||
|
ErrorKind::Denied
|
||||||
|
}
|
||||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||||
// A `ProtonError` that never reached the API is a transport failure.
|
// A `ProtonError` that never reached the API is a transport failure.
|
||||||
Error::Proton(_) => ErrorKind::Offline,
|
Error::Proton(_) => ErrorKind::Offline,
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ gtk4 = { version = "0.9", features = ["v4_12"] }
|
|||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
|
webkit6 = "0.4"
|
||||||
@@ -11,6 +11,7 @@ use pages::shared::*;
|
|||||||
use pages::shared_by_me::*;
|
use pages::shared_by_me::*;
|
||||||
use pages::status::*;
|
use pages::status::*;
|
||||||
use pages::trash::*;
|
use pages::trash::*;
|
||||||
|
use pages::verify::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||||
|
|
||||||
pub(crate) struct LoginState {
|
pub(crate) struct LoginState {
|
||||||
// Login page.
|
// Login page.
|
||||||
@@ -104,18 +105,35 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
ui.login.login_button.set_sensitive(false);
|
ui.login.login_button.set_sensitive(false);
|
||||||
ui.login.login_status.set_text("Signing in…");
|
ui.login.login_status.set_text("Signing in…");
|
||||||
let (rx, totp_req_rx) = spawn_login(username, password);
|
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||||
|
|
||||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||||
// back a code (or is cancelled, dropping the sender).
|
// back a code (or is cancelled, dropping the sender).
|
||||||
|
//
|
||||||
|
// Loops rather than awaiting once: a login gated behind a CAPTCHA is
|
||||||
|
// restarted after verification, so a 2FA account is asked for a second,
|
||||||
|
// unexpired code on the retry.
|
||||||
let ui_2fa = ui.clone();
|
let ui_2fa = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
if let Ok(code_tx) = totp_req_rx.recv().await {
|
while let Ok(code_tx) = totp_req_rx.recv().await {
|
||||||
prompt_2fa(&ui_2fa, code_tx);
|
prompt_2fa(&ui_2fa, code_tx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Same shape for human verification: only fires when the API gates the
|
||||||
|
// sign-in, and the worker is blocked on the token until it does.
|
||||||
|
let ui_hv = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||||
|
ui_hv
|
||||||
|
.login
|
||||||
|
.login_status
|
||||||
|
.set_text("Complete the verification to continue…");
|
||||||
|
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx
|
let result = rx
|
||||||
@@ -155,9 +173,13 @@ pub(crate) fn spawn_login(
|
|||||||
) -> (
|
) -> (
|
||||||
async_channel::Receiver<Result<(), String>>,
|
async_channel::Receiver<Result<(), String>>,
|
||||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||||
|
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||||
) {
|
) {
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(1);
|
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
||||||
|
// for a code on each attempt and a single slot would deadlock the worker.
|
||||||
|
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||||
|
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -170,24 +192,42 @@ pub(crate) fn spawn_login(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let result = rt.block_on(async move {
|
let result = rt.block_on(async move {
|
||||||
auth::login(&username, &password, || {
|
auth::login_interactive(
|
||||||
// 2FA required: hand a one-shot sender to the UI and block until
|
&username,
|
||||||
// the dialog supplies the code. A dropped sender (cancelled
|
&password,
|
||||||
// dialog) surfaces as a cancelled login.
|
|| {
|
||||||
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
// 2FA required: hand a one-shot sender to the UI and block
|
||||||
totp_req_tx
|
// until the dialog supplies the code. A dropped sender
|
||||||
.send_blocking(code_tx)
|
// (cancelled dialog) surfaces as a cancelled login.
|
||||||
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
|
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
||||||
code_rx
|
totp_req_tx
|
||||||
.recv()
|
.send_blocking(code_tx)
|
||||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
|
||||||
})
|
code_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||||
|
},
|
||||||
|
|hv| {
|
||||||
|
// Gated: hand the UI the page to show, block on the token the
|
||||||
|
// user earns by solving it.
|
||||||
|
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
||||||
|
hv_req_tx
|
||||||
|
.send_blocking((hv.verification_url(), token_tx))
|
||||||
|
.map_err(|_| {
|
||||||
|
pdfs_core::Error::Other("verification prompt closed".into())
|
||||||
|
})?;
|
||||||
|
let token = token_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||||
|
Ok(HumanVerificationCredential::captcha(token))
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
(rx, totp_req_rx)
|
(rx, totp_req_rx, hv_req_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub(crate) mod shared;
|
|||||||
pub(crate) mod shared_by_me;
|
pub(crate) mod shared_by_me;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
pub(crate) mod trash;
|
pub(crate) mod trash;
|
||||||
|
pub(crate) mod verify;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! The human-verification (CAPTCHA) dialog.
|
||||||
|
//!
|
||||||
|
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||||
|
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||||
|
//! expects the client to present Proton's hosted verification page, collect the
|
||||||
|
//! token the user earns by solving it, and retry the login carrying that token.
|
||||||
|
//!
|
||||||
|
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||||
|
//! browser because it reports completion by posting a message to its host, not
|
||||||
|
//! by redirecting: a browser has nowhere to post it back to.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
use webkit6::prelude::*;
|
||||||
|
|
||||||
|
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||||
|
///
|
||||||
|
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||||
|
/// the page itself — so nothing arrives unless the message is forwarded
|
||||||
|
/// explicitly. Injecting at document start guarantees the listener is installed
|
||||||
|
/// before the page can post anything.
|
||||||
|
const BRIDGE: &str = r#"
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.hv.postMessage(JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||||
|
///
|
||||||
|
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||||
|
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||||
|
pub(crate) fn prompt_human_verification(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
url: &str,
|
||||||
|
token_tx: std::sync::mpsc::Sender<String>,
|
||||||
|
) {
|
||||||
|
let content = webkit6::UserContentManager::new();
|
||||||
|
content.add_script(&webkit6::UserScript::new(
|
||||||
|
BRIDGE,
|
||||||
|
webkit6::UserContentInjectedFrames::AllFrames,
|
||||||
|
webkit6::UserScriptInjectionTime::Start,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
// Registration is what makes `messageHandlers.hv` exist in the page; without
|
||||||
|
// it the bridge above throws on every message.
|
||||||
|
content.register_script_message_handler("hv", None);
|
||||||
|
|
||||||
|
let webview = webkit6::WebView::builder()
|
||||||
|
.user_content_manager(&content)
|
||||||
|
.vexpand(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let dialog = adw::Dialog::builder()
|
||||||
|
.title("Verification")
|
||||||
|
.content_width(420)
|
||||||
|
.content_height(560)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
body.append(&header);
|
||||||
|
body.append(&webview);
|
||||||
|
dialog.set_child(Some(&body));
|
||||||
|
|
||||||
|
// Taken on the first success *or* on close, so the token is sent at most
|
||||||
|
// once and a cancelled dialog always drops the sender.
|
||||||
|
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||||
|
|
||||||
|
let tx = token_tx.clone();
|
||||||
|
let dlg = dialog.clone();
|
||||||
|
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||||
|
let Some(token) = extract_token(&value.to_str()) else {
|
||||||
|
// Every `postMessage` on the page reaches here, most of them the
|
||||||
|
// verification app's own chatter. Anything that is not a completion
|
||||||
|
// is simply not ours.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(tx) = tx.borrow_mut().take() {
|
||||||
|
let _ = tx.send(token);
|
||||||
|
}
|
||||||
|
dlg.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Covers the close button, Escape, and the user giving up.
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
token_tx.borrow_mut().take();
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.load_uri(url);
|
||||||
|
|
||||||
|
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||||
|
// Returns immediately; the login worker stays blocked on the channel until
|
||||||
|
// the handler above sends, or the dialog closes and drops the sender.
|
||||||
|
dialog.present(parent.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the verification token out of a message posted by the verification page.
|
||||||
|
///
|
||||||
|
/// Returns `None` for anything that is not a completion message. The page posts
|
||||||
|
/// a good deal besides — resize requests, readiness pings — and treating an
|
||||||
|
/// unrecognised shape as success would hand the API an empty token and fail the
|
||||||
|
/// login with a confusing error.
|
||||||
|
fn extract_token(raw: &str) -> Option<String> {
|
||||||
|
let value: serde_json::Value = serde_json::from_str(raw).ok()?;
|
||||||
|
// The payload is nested under `payload` and the message names itself in
|
||||||
|
// `type`; both spellings of the success type have shipped.
|
||||||
|
let kind = value.get("type")?.as_str()?;
|
||||||
|
if !matches!(
|
||||||
|
kind,
|
||||||
|
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let payload = value.get("payload")?;
|
||||||
|
let token = payload.get("token")?.as_str()?;
|
||||||
|
if token.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::extract_token;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_completion_message_yields_its_token() {
|
||||||
|
let raw =
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
|
||||||
|
assert_eq!(extract_token(raw).as_deref(), Some("tok-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page posts plenty that is not a completion; none of it may be
|
||||||
|
/// mistaken for one, or the login retries with a garbage token.
|
||||||
|
#[test]
|
||||||
|
fn unrelated_messages_are_ignored() {
|
||||||
|
for raw in [
|
||||||
|
r#"{"type":"resize","payload":{"height":400}}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
|
||||||
|
r#"{"payload":{"token":"tok"}}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
|
||||||
|
"not json at all",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user